diff --git a/openapi.yaml b/openapi.yaml index c3ecc302..7a6367d1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4157,6 +4157,19 @@ paths: properties: enabled: type: boolean + auto_deactivation: + type: object + required: [at, timezone, label] + properties: + at: + type: string + format: date-time + nullable: true + timezone: + type: string + example: Europe/Copenhagen + label: + type: string '404': $ref: '#/components/responses/NotFound' put: @@ -4189,6 +4202,21 @@ paths: properties: message: type: string + enabled: + type: boolean + auto_deactivation: + type: object + required: [at, timezone, label] + properties: + at: + type: string + format: date-time + nullable: true + timezone: + type: string + example: Europe/Copenhagen + label: + type: string '404': $ref: '#/components/responses/NotFound' diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 22a18591..76c42c81 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -30,6 +30,7 @@ use goals\helpers\goals_criteria_progress_alert_frequency as Freq; use objects\department_lanes_o; use objects\department_goals_o; use objects\department_selfserve_tasks_o; +use objects\department_variables_o; use objects\departments_o; use objects\bookings_o; use objects\logs_o; @@ -1524,6 +1525,7 @@ function SelfserveOpeningRelayActivationCron(): array $candidates = selfserveOpeningCleanerRelayActivationCandidates($now); $summary = [ 'checked_departments' => count($candidates), + 'disabled_departments' => 0, 'activated_departments' => 0, 'skipped_departments' => 0, 'failed_departments' => 0, @@ -1535,18 +1537,45 @@ function SelfserveOpeningRelayActivationCron(): array foreach ($candidates as $candidate) { $departmentId = (int)($candidate['department_id'] ?? 0); $opensAt = (string)($candidate['opens_at'] ?? ''); + $openingDate = (string)($candidate['opening_date'] ?? $now->format('Y-m-d')); if ($departmentId <= 0 || $opensAt === '') { $summary['skipped_departments']++; continue; } - $cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now); - if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) { - $summary['skipped_departments']++; + $cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $openingDate); + + try { + $transition = department_variables_o::withSelfServeTransitionLock( + $departmentId, + static function () use ($departmentId): array { + $departmentSummary = selfserveActivateStaffedDefaultRelaysForDepartment($departmentId); + if ((int)$departmentSummary['failed'] > 0) { + return [ + 'department_summary' => $departmentSummary, + 'department_disabled' => false, + ]; + } + + return [ + 'department_summary' => $departmentSummary, + 'department_disabled' => selfserveDisableDepartmentSelfServeForOpening($departmentId), + ]; + }, + 0 + ); + } catch (Throwable $throwable) { + $summary['failed_departments']++; + warn( + 'SelfserveOpeningRelayActivationCron could not serialize transition for department ' + . $departmentId + . ': ' + . $throwable->getMessage() + ); continue; } - $departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId); + $departmentSummary = $transition['department_summary']; $summary['activated_relays'] += (int)$departmentSummary['activated']; $summary['skipped_relays'] += (int)$departmentSummary['skipped']; $summary['failed_relays'] += (int)$departmentSummary['failed']; @@ -1556,6 +1585,13 @@ function SelfserveOpeningRelayActivationCron(): array continue; } + $departmentDisabled = (bool)$transition['department_disabled']; + if (!$departmentDisabled) { + $summary['failed_departments']++; + continue; + } + + $summary['disabled_departments']++; selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey); if ((int)$departmentSummary['activated'] > 0) { $summary['activated_departments']++; @@ -1565,7 +1601,8 @@ function SelfserveOpeningRelayActivationCron(): array } echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: " - . $summary['activated_relays'] . " cleaner relays activated across " + . $summary['disabled_departments'] . " departments disabled self-serve, " + . $summary['activated_relays'] . " staffed-default relays activated across " . $summary['activated_departments'] . " departments.\n"; return $summary; @@ -1583,23 +1620,32 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now $startColumn = $weekday . '_start'; $endColumn = $weekday . '_end'; + $previousDate = $now->modify('-1 day'); + $previousWeekday = strtolower($previousDate->format('l')); + $previousStartColumn = $previousWeekday . '_start'; + $previousEndColumn = $previousWeekday . '_end'; $sql = " SELECT oh.department AS department_id, - TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at, - TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at + TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS current_opens_at, + TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS current_closes_at, + TIME_FORMAT(oh.`$previousStartColumn`, '%H:%i:%s') AS previous_opens_at, + TIME_FORMAT(oh.`$previousEndColumn`, '%H:%i:%s') AS previous_closes_at FROM department_time_bookings_opening_hours oh INNER JOIN department_variables dv ON dv.department_id = oh.department - INNER JOIN department_lanes dl ON dl.department = oh.department WHERE dv.variable = 'selfserve_enabled' AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on') - AND oh.`$startColumn` IS NOT NULL - AND oh.`$endColumn` IS NOT NULL - AND dl.deleted_at IS NULL - AND COALESCE(dl.selfserve_enabled, 1) = 1 - AND dl.relay_machine_cleaner_id IS NOT NULL - AND TRIM(dl.relay_machine_cleaner_id) <> '' - GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn` + AND ( + (oh.`$startColumn` IS NOT NULL AND oh.`$endColumn` IS NOT NULL) + OR + (oh.`$previousStartColumn` IS NOT NULL AND oh.`$previousEndColumn` IS NOT NULL) + ) + GROUP BY + oh.department, + oh.`$startColumn`, + oh.`$endColumn`, + oh.`$previousStartColumn`, + oh.`$previousEndColumn` "; $result = $db->query($sql); @@ -1607,14 +1653,33 @@ function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now return []; } - return array_values(array_filter( - $db->fetch_all($result), - static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive( - $now, - (string)($row['opens_at'] ?? ''), - (string)($row['closes_at'] ?? '') - ) - )); + $candidates = []; + foreach ($db->fetch_all($result) as $row) { + $currentOpensAt = (string)($row['current_opens_at'] ?? ''); + $currentClosesAt = (string)($row['current_closes_at'] ?? ''); + if (selfserveOpeningCleanerRelayWindowActive($now, $currentOpensAt, $currentClosesAt)) { + $candidates[] = [ + 'department_id' => $row['department_id'] ?? null, + 'opens_at' => $currentOpensAt, + 'closes_at' => $currentClosesAt, + 'opening_date' => $now->format('Y-m-d'), + ]; + continue; + } + + $previousOpensAt = (string)($row['previous_opens_at'] ?? ''); + $previousClosesAt = (string)($row['previous_closes_at'] ?? ''); + if (selfserveOpeningCleanerRelayOvernightCarryoverActive($now, $previousOpensAt, $previousClosesAt)) { + $candidates[] = [ + 'department_id' => $row['department_id'] ?? null, + 'opens_at' => $previousOpensAt, + 'closes_at' => $previousClosesAt, + 'opening_date' => $previousDate->format('Y-m-d'), + ]; + } + } + + return $candidates; } function selfserveOpeningCleanerRelayWindowActive( @@ -1639,6 +1704,24 @@ function selfserveOpeningCleanerRelayWindowActive( return $nowSeconds >= $opensAtSeconds; } +function selfserveOpeningCleanerRelayOvernightCarryoverActive( + DateTimeImmutable $now, + ?string $opensAt, + ?string $closesAt +): bool { + $opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt); + $closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt); + if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds <= $closesAtSeconds) { + return false; + } + + $nowSeconds = ((int)$now->format('G') * 3600) + + ((int)$now->format('i') * 60) + + (int)$now->format('s'); + + return $nowSeconds < $closesAtSeconds; +} + function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int { $time = trim((string)$time); @@ -1661,7 +1744,27 @@ function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int return ($hours * 3600) + ($minutes * 60) + $seconds; } -function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array +function selfserveDisableDepartmentSelfServeForOpening(int $departmentId): bool +{ + try { + (new department_variables_o()) + ->selectDepartment($departmentId) + ->set('selfserve_enabled', 'false'); + + return true; + } catch (Throwable $throwable) { + warn( + 'SelfserveOpeningRelayActivationCron failed to disable self-serve for department ' + . $departmentId + . ': ' + . $throwable->getMessage() + ); + + return false; + } +} + +function selfserveActivateStaffedDefaultRelaysForDepartment(int $departmentId): array { $summary = [ 'activated' => 0, @@ -1677,17 +1780,7 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): continue; } - try { - if (!$departmentLane->isSelfServeEnabled()) { - $summary['skipped']++; - continue; - } - } catch (Throwable) { - $summary['skipped']++; - continue; - } - - if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) { + if (!selfserveLaneHasAnyConfiguredStaffedDefaultRelay($departmentLane)) { $summary['skipped']++; continue; } @@ -1700,13 +1793,15 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): try { $lane = $selfserve->lane($laneId); - if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) { - $summary['skipped']++; - continue; - } - - $lane->setMachineCleanerRelayStatusHard(true); - $summary['activated']++; + $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { + $lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true); + }); + $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { + $lane->setMachineCleanerRelayStatusForDepartmentOperation(true); + }); + $summary['activated'] += selfserveSetOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void { + $lane->setMachineRelayStatusForDepartmentOperation(true); + }); } catch (Throwable $throwable) { $summary['failed']++; warn( @@ -1723,19 +1818,31 @@ function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): return $summary; } +function selfserveLaneHasAnyConfiguredStaffedDefaultRelay(?object $departmentLane): bool +{ + return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_program_picker_id') + || selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id') + || selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_id'); +} + function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool +{ + return selfserveLaneHasConfiguredRelay($departmentLane, 'relay_machine_cleaner_id'); +} + +function selfserveLaneHasConfiguredRelay(?object $departmentLane, string $relayProperty): bool { if ( $departmentLane === null - || !isset($departmentLane->relay_machine_cleaner_id) - || !is_object($departmentLane->relay_machine_cleaner_id) - || !method_exists($departmentLane->relay_machine_cleaner_id, 'value') + || !isset($departmentLane->{$relayProperty}) + || !is_object($departmentLane->{$relayProperty}) + || !method_exists($departmentLane->{$relayProperty}, 'value') ) { return false; } try { - $relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value()); + $relayId = trim((string)$departmentLane->{$relayProperty}->value()); } catch (Throwable) { return false; } @@ -1743,14 +1850,35 @@ function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null'; } +function selfserveSetOptionalLaneRelayState(object $lane, string $relayProperty, callable $callback): int +{ + if (!selfserveLaneHasConfiguredRelay($lane->department_lane ?? null, $relayProperty)) { + return 0; + } + + try { + $callback(); + return 1; + } catch (Throwable $throwable) { + warn( + 'SelfserveOpeningRelayActivationCron failed to activate relay ' + . $relayProperty + . ': ' + . $throwable->getMessage() + ); + + throw $throwable; + } +} + function selfserveOpeningCleanerRelayActivationKey( int $departmentId, string $opensAt, - DateTimeImmutable $now + string $openingDate ): string { $normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown'; return 'selfserve:opening-cleaner-relays:' - . $now->format('Y-m-d') + . $openingDate . ':' . $departmentId . ':' diff --git a/services/nginx/app/modules/selfserve/cron/tasks.php b/services/nginx/app/modules/selfserve/cron/tasks.php index 4a3f452a..9e3f2db4 100644 --- a/services/nginx/app/modules/selfserve/cron/tasks.php +++ b/services/nginx/app/modules/selfserve/cron/tasks.php @@ -4,8 +4,8 @@ return [ [ 'id' => 'selfserve.activate_opening_cleaner_relays', 'legacy_name' => 'SelfserveOpeningRelayActivationCron', - 'name' => 'Activate self-serve opening cleaner relays', - 'description' => 'Turns configured self-serve cleaner relays on when a department enters opening hours.', + 'name' => 'Restore staffed relays and disable department self-serve at opening', + 'description' => 'Restores every configured lane relay to its staffed default before disabling department self-serve when staffed opening hours begin.', 'module' => 'selfserve', 'handler' => 'SelfserveOpeningRelayActivationCron', 'schedule' => ['type' => 'interval', 'seconds' => 60], diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php index 792b30ab..306380c8 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php @@ -130,6 +130,11 @@ trait selfserve_lane_relay_controller_t return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); } + public function setMachineRelayStatusForDepartmentOperation(bool $on): bool + { + return $this->sendRelaySwitchCommandForDepartmentOperation(selfserve_lane_relay::MACHINE, $on); + } + /** * Set PROGRAM SELECTOR relay status directly, bypassing lane status guards. * Intended for department-level operational toggles. @@ -161,6 +166,14 @@ trait selfserve_lane_relay_controller_t return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); } + public function setMachineProgramPickerRelayStatusForDepartmentOperation(bool $on): bool + { + return $this->sendRelaySwitchCommandForDepartmentOperation( + selfserve_lane_relay::MACHINE_PROGRAM_PICKER, + $on + ); + } + /** * Set MACHINE_CLEANER relay status directly. * @param bool $on true to turn on, false to turn off @@ -182,6 +195,14 @@ trait selfserve_lane_relay_controller_t return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, $on); } + public function setMachineCleanerRelayStatusForDepartmentOperation(bool $on): bool + { + return $this->sendRelaySwitchCommandForDepartmentOperation( + selfserve_lane_relay::MACHINE_CLEANER, + $on + ); + } + /** * Set relay status directly for a specific relay type. * @param selfserve_lane_relay $relay @@ -1033,6 +1054,20 @@ trait selfserve_lane_relay_controller_t throw new \Exception("Cannot change relay state: Self-serve is not enabled for this lane's department."); } + return $this->sendRelaySwitchCommandForDepartmentOperation($relay, $on, $duration); + } + + /** + * Department-level transitions deliberately bypass lane self-serve guards. + * Callers must serialize the transition before using this path. + * + * @throws \Exception + */ + private function sendRelaySwitchCommandForDepartmentOperation( + selfserve_lane_relay $relay, + bool $on, + ?int $duration = null + ): bool { $relay_id = $this->getRelayId($relay); $payload = [ 'id' => $relay_id, diff --git a/services/nginx/app/objects/department_time_bookings_opening_hours_o.php b/services/nginx/app/objects/department_time_bookings_opening_hours_o.php index 43eaeea3..9231facf 100644 --- a/services/nginx/app/objects/department_time_bookings_opening_hours_o.php +++ b/services/nginx/app/objects/department_time_bookings_opening_hours_o.php @@ -4,6 +4,7 @@ namespace objects; use classes\db; use classes\object_property; +use DateTimeImmutable; use Exception; use traits\db_object_t; @@ -81,6 +82,22 @@ class department_time_bookings_opening_hours_o extends db } } + public function selectExistingByDepartment(int $department_id): bool + { + $matches = self::getFieldsWhere([ + 'department' => $department_id + ], [ + 'id' + ]); + if (count($matches) === 0) { + return false; + } + + $this->id = $matches[0]['id']; + self::getObjectProperties(); + return true; + } + public function getObjectProperties(): void { $this->department = new object_property($this->table, $this->id, 'department', 'int', true); @@ -197,6 +214,90 @@ class department_time_bookings_opening_hours_o extends db return ($startTime >= $openingStartTime && $endTime <= $openingEndTime); } + public function getNextOpeningStart(DateTimeImmutable $now): ?DateTimeImmutable + { + self::requireSelected(); + + $previousDay = $now->modify('-1 day'); + $previousWeekdayName = self::getWeekdayName((int)$previousDay->format('N')); + $previousOpeningStart = $this->normalizeOpeningTime( + $this->{"{$previousWeekdayName}_start"}->value() + ); + $previousOpeningEnd = $this->normalizeOpeningTime( + $this->{"{$previousWeekdayName}_end"}->value() + ); + if ( + $previousOpeningStart !== null + && $previousOpeningEnd !== null + && $previousOpeningStart > $previousOpeningEnd + ) { + $carryoverEnd = $now->setTime( + $previousOpeningEnd[0], + $previousOpeningEnd[1], + $previousOpeningEnd[2] + ); + if ($now < $carryoverEnd) { + return $now; + } + } + + for ($dayOffset = 0; $dayOffset <= 7; $dayOffset++) { + $candidateDay = $now->modify('+' . $dayOffset . ' days'); + $weekdayName = self::getWeekdayName((int)$candidateDay->format('N')); + $openingStart = $this->normalizeOpeningTime($this->{"{$weekdayName}_start"}->value()); + $openingEnd = $this->normalizeOpeningTime($this->{"{$weekdayName}_end"}->value()); + + if ( + $openingStart === null + || $openingEnd === null + || $openingStart === $openingEnd + ) { + continue; + } + + $candidateStart = $candidateDay->setTime($openingStart[0], $openingStart[1], $openingStart[2]); + if ($dayOffset === 0) { + $candidateEnd = $candidateDay->setTime($openingEnd[0], $openingEnd[1], $openingEnd[2]); + if ($candidateEnd <= $candidateStart) { + $candidateEnd = $candidateEnd->modify('+1 day'); + } + + if ($now >= $candidateStart && $now < $candidateEnd) { + return $now; + } + if ($candidateStart < $now) { + continue; + } + } + + return $candidateStart; + } + + return null; + } + + private function normalizeOpeningTime(mixed $value): ?array + { + $value = trim((string)$value); + if ($value === '') { + return null; + } + + if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $value, $matches)) { + return null; + } + + $hours = (int)$matches[1]; + $minutes = (int)$matches[2]; + $seconds = isset($matches[3]) ? (int)$matches[3] : 0; + + if ($hours > 23 || $minutes > 59 || $seconds > 59) { + return null; + } + + return [$hours, $minutes, $seconds]; + } + /** * Get the name of the day of the week * @param int $dayOfWeek The day of the week (1 = Monday, 7 = Sunday) @@ -216,4 +317,4 @@ class department_time_bookings_opening_hours_o extends db default => throw new Exception('Invalid day of the week'), }; } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/department_variables_o.php b/services/nginx/app/objects/department_variables_o.php index d64527e0..92e58ec9 100644 --- a/services/nginx/app/objects/department_variables_o.php +++ b/services/nginx/app/objects/department_variables_o.php @@ -59,6 +59,68 @@ class department_variables_o extends db } } + public static function withSelfServeTransitionLock( + int $departmentId, + callable $callback, + int $timeoutSeconds = 5 + ): mixed { + if ($departmentId <= 0) { + throw new \InvalidArgumentException('A valid department is required for the self-serve transition lock.'); + } + + $lockName = 'selfserve:department-transition:' . $departmentId; + if (!self::acquireSelfServeTransitionLock($lockName, max(0, $timeoutSeconds))) { + throw new \RuntimeException('The department self-serve transition is already in progress.'); + } + + try { + return $callback(); + } finally { + try { + self::releaseSelfServeTransitionLock($lockName); + } catch (\Throwable) { + } + } + } + + private static function acquireSelfServeTransitionLock(string $lockName, int $timeoutSeconds): bool + { + global $db; + + $statement = $db->prepare('SELECT GET_LOCK(?, ?) AS acquired'); + if ($statement === false) { + throw new \RuntimeException('Could not prepare the department self-serve transition lock.'); + } + + try { + $statement->bind_param('si', $lockName, $timeoutSeconds); + $statement->execute(); + $result = $statement->get_result(); + $row = $result instanceof \mysqli_result ? $result->fetch_assoc() : null; + + return (int)($row['acquired'] ?? 0) === 1; + } finally { + $statement->close(); + } + } + + private static function releaseSelfServeTransitionLock(string $lockName): void + { + global $db; + + $statement = $db->prepare('SELECT RELEASE_LOCK(?) AS released'); + if ($statement === false) { + throw new \RuntimeException('Could not prepare the department self-serve transition lock release.'); + } + + try { + $statement->bind_param('s', $lockName); + $statement->execute(); + } finally { + $statement->close(); + } + } + /** * Require the department ID to be set, and throw an exception if it is not * @throws Exception If the department ID is not set. @@ -198,4 +260,4 @@ class department_variables_o extends db return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index a8ea8fe9..b622fec6 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -4600,6 +4600,19 @@ paths: properties: enabled: type: boolean + auto_deactivation: + type: object + required: [at, timezone, label] + properties: + at: + type: string + format: date-time + nullable: true + timezone: + type: string + example: Europe/Copenhagen + label: + type: string '404': $ref: '#/components/responses/NotFound' put: @@ -4632,6 +4645,21 @@ paths: properties: message: type: string + enabled: + type: boolean + auto_deactivation: + type: object + required: [at, timezone, label] + properties: + at: + type: string + format: date-time + nullable: true + timezone: + type: string + example: Europe/Copenhagen + label: + type: string '404': $ref: '#/components/responses/NotFound' diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index e0bfcc79..890a734a 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -4,9 +4,12 @@ namespace routes; use classes\authentication; use classes\selfserve; +use DateTimeImmutable; +use DateTimeZone; use objects\categories_o; use objects\department_categories_o; use objects\department_lanes_o; +use objects\department_time_bookings_opening_hours_o; use objects\department_variables_o; use objects\departments_o; use objects\logs_o; @@ -330,9 +333,9 @@ class departmentsRoute $enabled = $department_variables->getVariable('selfserve_enabled'); // Return the status - $response->success([ - 'enabled' => $enabled === true - ]); + $response->success( + $this->buildDepartmentSelfServeEnabledPayload((int)$department->id, $enabled === true) + ); } else { // Return an error $response->error('Invalid session', 400); @@ -368,14 +371,22 @@ class departmentsRoute // Set the department variable $department_variables = (new department_variables_o())->selectDepartment($department->id); $enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1'; - $department_variables->set('selfserve_enabled', $enabled ? 'true' : 'false'); - $this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled); + department_variables_o::withSelfServeTransitionLock( + (int)$department->id, + function () use ($department_variables, $department, $enabled): void { + $department_variables->set('selfserve_enabled', $enabled ? 'true' : 'false'); + $this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled); + } + ); // Log the incident (new logs_o())->add('departments', $department->id, 1, $user->id, 'EDIT_DEPARTMENT_SELFSERVE_ENABLED', 'Successfully edited department self-serve enabled status to ' . ($enabled ? 'true' : 'false')); // Return a success message - $response->success(['message' => 'Department self-serve enabled status updated successfully']); + $response->success([ + 'message' => 'Department self-serve enabled status updated successfully', + ...$this->buildDepartmentSelfServeEnabledPayload((int)$department->id, $enabled) + ]); } else { // Return an error $response->error('Invalid session', 400); @@ -593,13 +604,13 @@ class departmentsRoute if (!$enabled) { // Self-serve disabled: restore normal/manual relay operation. $this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { - $lane->setMachineProgramPickerRelayStatusHard(true); + $lane->setMachineProgramPickerRelayStatusForDepartmentOperation(true); }); $this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { - $lane->setMachineCleanerRelayStatusHard(true); + $lane->setMachineCleanerRelayStatusForDepartmentOperation(true); }); $this->setOptionalLaneRelayState($lane, 'relay_machine_id', static function () use ($lane): void { - $lane->setMachineRelayStatusHard(true); + $lane->setMachineRelayStatusForDepartmentOperation(true); }); continue; } @@ -643,4 +654,38 @@ class departmentsRoute // Best effort only; this endpoint should still update the department variable. } } + + private function buildDepartmentSelfServeEnabledPayload(int $departmentId, bool $enabled): array + { + $nextDeactivationAt = $enabled + ? $this->getDepartmentSelfServeAutoDeactivationAt($departmentId) + : null; + + return [ + 'enabled' => $enabled, + 'auto_deactivation' => [ + 'at' => $nextDeactivationAt?->format(DATE_ATOM), + 'timezone' => 'Europe/Copenhagen', + 'label' => $nextDeactivationAt === null + ? 'NEVER' + : $nextDeactivationAt->format('Y-m-d H:i:s'), + ], + ]; + } + + private function getDepartmentSelfServeAutoDeactivationAt(int $departmentId): ?DateTimeImmutable + { + try { + $openingHours = new department_time_bookings_opening_hours_o(); + if (!$openingHours->selectExistingByDepartment($departmentId)) { + return null; + } + + return $openingHours->getNextOpeningStart( + new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen')) + ); + } catch (\Throwable) { + return null; + } + } } diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php index 0b2fe852..04a319ab 100644 --- a/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php @@ -6,16 +6,28 @@ it('syncs lane relay states when department self-serve enabled flag changes', fu expect($routeContent)->not->toBeFalse(); expect($routeContent)->toContain('/departments/self-serve/enabled'); expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);'); + expect($routeContent)->toContain('department_variables_o::withSelfServeTransitionLock'); expect($routeContent)->toContain('if (!$enabled) {'); expect($routeContent)->toContain('// Self-serve disabled: restore normal/manual relay operation.'); - expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(true)'); - expect($routeContent)->toContain('setMachineRelayStatusHard(true)'); + expect($routeContent)->toContain('setMachineProgramPickerRelayStatusForDepartmentOperation(true)'); + expect($routeContent)->toContain('setMachineRelayStatusForDepartmentOperation(true)'); expect($routeContent)->toContain('!$department_lane->isSelfServeEnabled()'); expect($routeContent)->toContain('setMachineProgramPickerRelayStatus(false)'); - expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(true)'); + expect($routeContent)->toContain('setMachineCleanerRelayStatusForDepartmentOperation(true)'); expect($routeContent)->toContain('setMachineRelayStatus(false)'); expect($routeContent)->not->toContain('setMachineProgramPickerRelayStatusHard(false)'); expect($routeContent)->not->toContain('setMachineCleanerRelayStatusHard(false)'); expect($routeContent)->not->toContain('setMachineRelayStatusHard(false)'); expect($routeContent)->not->toContain('setMachineCleanerRelayStatus(false)'); }); + +it('uses the supported prepared database API for the department transition lock', function (): void { + $variableObject = file_get_contents(app_path('objects/department_variables_o.php')); + + expect($variableObject)->not->toBeFalse() + ->and($variableObject)->not->toContain('$database->selectOne') + ->and($variableObject)->toContain("\$db->prepare('SELECT GET_LOCK(?, ?) AS acquired')") + ->and($variableObject)->toContain("\$statement->bind_param('si', \$lockName, \$timeoutSeconds)") + ->and($variableObject)->toContain("\$db->prepare('SELECT RELEASE_LOCK(?) AS released')") + ->and($variableObject)->toContain("\$statement->bind_param('s', \$lockName)"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php index 5a6fc362..bf54e281 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpeningRelayActivationCronTest.php @@ -7,10 +7,12 @@ it('registers an opening-time self-serve cleaner relay activation cron', functio expect($tasks[0]['id'])->toBe('selfserve.activate_opening_cleaner_relays'); expect($tasks[0]['legacy_name'])->toBe('SelfserveOpeningRelayActivationCron'); expect($tasks[0]['handler'])->toBe('SelfserveOpeningRelayActivationCron'); + expect($tasks[0]['name'])->toContain('disable department self-serve'); + expect($tasks[0]['description'])->toContain('every configured lane relay'); expect($tasks[0]['schedule'])->toBe(['type' => 'interval', 'seconds' => 60]); }); -it('activates only configured cleaner relays after department opening time', function (): void { +it('disables department self-serve and restores staffed relay defaults after department opening time', function (): void { $cronContent = file_get_contents(app_path('cron/Cron.php')); expect($cronContent)->not->toBeFalse(); @@ -18,11 +20,41 @@ it('activates only configured cleaner relays after department opening time', fun expect($cronContent)->toContain("new DateTimeZone('Europe/Copenhagen')"); expect($cronContent)->toContain('department_time_bookings_opening_hours'); expect($cronContent)->toContain("dv.variable = 'selfserve_enabled'"); - expect($cronContent)->toContain('dl.selfserve_enabled'); - expect($cronContent)->toContain('relay_machine_cleaner_id'); + expect($cronContent)->toContain("->set('selfserve_enabled', 'false')"); + expect($cronContent)->toContain('use objects\department_variables_o;'); + expect($cronContent)->toContain('selfserveActivateStaffedDefaultRelaysForDepartment'); expect($cronContent)->toContain('$nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds'); - expect($cronContent)->toContain('setMachineCleanerRelayStatusHard(true)'); + expect($cronContent)->toContain('selfserveOpeningCleanerRelayOvernightCarryoverActive'); + expect($cronContent)->toContain('$previousDate = $now->modify(\'-1 day\')'); + expect($cronContent)->not->toContain('if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey))'); + expect($cronContent)->not->toContain('if (!$departmentLane->isSelfServeEnabled())'); + expect($cronContent)->toContain('throw $throwable;'); + expect($cronContent)->toContain('department_variables_o::withSelfServeTransitionLock'); + expect(strpos($cronContent, 'selfserveActivateStaffedDefaultRelaysForDepartment($departmentId)')) + ->toBeLessThan(strpos($cronContent, 'selfserveDisableDepartmentSelfServeForOpening($departmentId)')); + expect($cronContent)->toContain('setMachineProgramPickerRelayStatusForDepartmentOperation(true)'); + expect($cronContent)->toContain('setMachineCleanerRelayStatusForDepartmentOperation(true)'); + expect($cronContent)->toContain('setMachineRelayStatusForDepartmentOperation(true)'); expect($cronContent)->toContain('selfserve:opening-cleaner-relays:'); - expect($cronContent)->not->toContain('setMachineRelayStatusHard(true)'); - expect($cronContent)->not->toContain('setMachineProgramPickerRelayStatusHard(true)'); +}); + +it('exposes next self-serve auto deactivation metadata from the department endpoint', function (): void { + $routeContent = file_get_contents(app_path('routes/departmentsRoute.php')); + $openingHoursContent = file_get_contents(app_path('objects/department_time_bookings_opening_hours_o.php')); + + expect($routeContent)->not->toBeFalse(); + expect($openingHoursContent)->not->toBeFalse(); + expect($routeContent)->toContain('buildDepartmentSelfServeEnabledPayload'); + expect($routeContent)->toContain('selectExistingByDepartment($departmentId)'); + expect($routeContent)->toContain("'auto_deactivation'"); + expect($routeContent)->toContain("'label' => \$nextDeactivationAt === null"); + expect($routeContent)->toContain("'NEVER'"); + expect($openingHoursContent)->toContain('function getNextOpeningStart(DateTimeImmutable $now): ?DateTimeImmutable'); + expect($openingHoursContent)->toContain('$openingStart === null'); + expect($openingHoursContent)->toContain('$openingEnd === null'); + expect($openingHoursContent)->toContain('$openingStart === $openingEnd'); + expect($openingHoursContent)->toContain('$dayOffset <= 7'); + expect($openingHoursContent)->toContain('$previousWeekdayName'); + expect($openingHoursContent)->toContain('return $now;'); + expect(file_get_contents(app_path('openapi.yaml')))->toContain('auto_deactivation:'); });