From 527e6a9ec468747bab70d2a3ddc1f33fcc2bac59 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 11 Mar 2026 13:56:29 +0100 Subject: [PATCH] Document WeatherAPI and department weather endpoints in OpenAPI --- openapi.yaml | 169 +++++++++++ services/nginx/app/classes/weatherapi.php | 129 +++++++++ .../nginx/app/interfaces/weatherapi_i.php | 30 ++ .../config/weatherapi_enabled_c.php | 29 ++ .../config/weatherapi_secret_key_c.php | 29 ++ .../app/modules/weatherapi/weatherapi_c.php | 30 ++ .../nginx/app/routes/moduleConfigRoute.php | 40 +++ .../app/routes/moduleWeatherAPIRoute.php | 265 ++++++++++++++++++ 8 files changed, 721 insertions(+) create mode 100644 services/nginx/app/classes/weatherapi.php create mode 100644 services/nginx/app/interfaces/weatherapi_i.php create mode 100644 services/nginx/app/modules/weatherapi/config/weatherapi_enabled_c.php create mode 100644 services/nginx/app/modules/weatherapi/config/weatherapi_secret_key_c.php create mode 100644 services/nginx/app/modules/weatherapi/weatherapi_c.php create mode 100644 services/nginx/app/routes/moduleWeatherAPIRoute.php diff --git a/openapi.yaml b/openapi.yaml index cb100a03..31c9d47f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6470,6 +6470,99 @@ paths: '200': description: Exchange rates retrieved successfully + + /modules/weatherapi/current: + get: + tags: + - Modules + summary: Get current weather + description: Get current weather data from WeatherAPI for a location query + operationId: weatherApiCurrent + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + responses: + '200': + description: Current weather retrieved successfully + + /modules/weatherapi/forecast: + get: + tags: + - Modules + summary: Get weather forecast + description: Get forecast weather data from WeatherAPI + operationId: weatherApiForecast + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + - name: days + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 14 + description: Number of forecast days + responses: + '200': + description: Forecast weather retrieved successfully + + /modules/weatherapi/search: + get: + tags: + - Modules + summary: Search weather locations + description: Search location suggestions from WeatherAPI + operationId: weatherApiSearch + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Search text + responses: + '200': + description: Location search results retrieved successfully + + /departments/weather: + get: + tags: + - Departments + summary: Get department weather timeline + description: Returns hourly weather, washes, hours and productivity status for a department + operationId: getDepartmentWeatherTimeline + parameters: + - name: id + in: query + required: true + schema: + type: integer + minimum: 1 + description: Department ID + responses: + '200': + description: Department weather timeline retrieved successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SuccessResponse' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DepartmentWeatherTimelineEntry' + /modules/entra/users: get: tags: @@ -6923,6 +7016,31 @@ paths: schema: $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /weatherapi/config: + get: + tags: [Config] + summary: Get WeatherAPI config + operationId: getWeatherApiConfig + responses: + '200': + description: WeatherAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiConfigListResponse' + post: + tags: [Config] + summary: Update WeatherAPI config + operationId: updateWeatherApiConfig + responses: + '200': + description: WeatherAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + /gatewayapi/config: get: tags: [Config] @@ -7898,6 +8016,19 @@ components: - type: string required: [module, variable, type, value] + + WeatherApiConfigEntry: + type: object + properties: + module: { type: string, enum: [weatherapi] } + variable: { type: string, enum: [enabled, secret_key] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + GatewayApiConfigEntry: type: object properties: @@ -8083,6 +8214,15 @@ components: data: { type: array, items: { $ref: '#/components/schemas/FxRatesApiConfigEntry' } } required: [data] + + WeatherApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WeatherApiConfigEntry' } } + required: [data] + GatewayApiConfigListResponse: allOf: - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' @@ -8163,6 +8303,35 @@ components: data: { type: array, items: { $ref: '#/components/schemas/SelfServeConfigEntry' } } required: [data] + + DepartmentWeatherStatus: + type: string + enum: [unknown, healthy, degraded, unhealthy] + + DepartmentWeatherCondition: + type: string + enum: [clear, mostly_clear, partly_cloudy, mostly_cloudy, overcast, rain, showers, thunderstorm, snow, fog] + + DepartmentWeatherTimelineEntry: + type: object + properties: + time: + type: string + example: '01:00' + weather: + $ref: '#/components/schemas/DepartmentWeatherCondition' + washes: + type: integer + minimum: 0 + example: 0 + hours: + type: integer + minimum: 0 + example: 10 + status: + $ref: '#/components/schemas/DepartmentWeatherStatus' + required: [time, weather, washes, hours, status] + ModuleConfigEntry: type: object properties: diff --git a/services/nginx/app/classes/weatherapi.php b/services/nginx/app/classes/weatherapi.php new file mode 100644 index 00000000..4994ead9 --- /dev/null +++ b/services/nginx/app/classes/weatherapi.php @@ -0,0 +1,129 @@ +config = new weatherapi_c(); + } + + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The weatherapi module is not enabled'); + } + } + + public function requireValidSecretKey(): void + { + if ($this->config->secret_key->getVariableValue() === null || $this->config->secret_key->getVariableValue() === '') { + throw new Exception('Invalid secret key defined in the config (weatherapi_secret_key_c)'); + } + } + + public function sendRequest(string $endpoint, array $query = []): object + { + $this->requireModuleEnabled(); + $this->requireValidSecretKey(); + + $endpoint = ltrim($endpoint, '/'); + $query = array_merge(['key' => $this->config->secret_key->getVariableValue()], $query); + $url = $this->api_url . $endpoint . '?' . http_build_query($query); + + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => 'GET', + ]); + + $response = curl_exec($curl); + $status_code = curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + + if ($error) { + throw new Exception('cURL request failed: ' . $error); + } + + if ($response === false || $response === '') { + throw new Exception('Empty response from WeatherAPI'); + } + + $decoded = json_decode($response); + if (json_last_error() !== JSON_ERROR_NONE || !is_object($decoded) && !is_array($decoded)) { + throw new Exception('Invalid response from WeatherAPI'); + } + + if ($status_code >= 400) { + $message = 'WeatherAPI request failed with HTTP ' . $status_code; + if (is_object($decoded) && isset($decoded->error->message)) { + $message .= ': ' . $decoded->error->message; + } + throw new Exception($message); + } + + return (object)$decoded; + } + + public function current(string $query, array $options = []): object + { + return $this->sendRequest('current.json', array_merge(['q' => $query], $options)); + } + + public function forecast(string $query, int $days = 1, array $options = []): object + { + return $this->sendRequest('forecast.json', array_merge(['q' => $query, 'days' => $days], $options)); + } + + public function history(string $query, string $date, array $options = []): object + { + return $this->sendRequest('history.json', array_merge(['q' => $query, 'dt' => $date], $options)); + } + + public function astronomy(string $query, string $date, array $options = []): object + { + return $this->sendRequest('astronomy.json', array_merge(['q' => $query, 'dt' => $date], $options)); + } + + public function timezone(string $query, array $options = []): object + { + return $this->sendRequest('timezone.json', array_merge(['q' => $query], $options)); + } + + public function sports(string $query, array $options = []): object + { + return $this->sendRequest('sports.json', array_merge(['q' => $query], $options)); + } + + public function search(string $query, array $options = []): object + { + return $this->sendRequest('search.json', array_merge(['q' => $query], $options)); + } + + public function marine(string $query, int $days = 1, array $options = []): object + { + return $this->sendRequest('marine.json', array_merge(['q' => $query, 'days' => $days], $options)); + } + + public function future(string $query, string $date, array $options = []): object + { + return $this->sendRequest('future.json', array_merge(['q' => $query, 'dt' => $date], $options)); + } +} diff --git a/services/nginx/app/interfaces/weatherapi_i.php b/services/nginx/app/interfaces/weatherapi_i.php new file mode 100644 index 00000000..dc2dcc69 --- /dev/null +++ b/services/nginx/app/interfaces/weatherapi_i.php @@ -0,0 +1,30 @@ +setupConfig('weatherapi'); + $this->allowUpdate([ + weatherapi_enabled_c::class, + weatherapi_secret_key_c::class, + ]); + + $this->enabled = new weatherapi_enabled_c(); + $this->secret_key = new weatherapi_secret_key_c(); + } +} diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index 20b94322..063d7faa 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -12,6 +12,7 @@ use classes\recaptcha; use classes\response; use classes\router; use classes\stripe; +use classes\weatherapi; use objects\logs_o; use traits\route_t; @@ -412,6 +413,45 @@ class moduleConfigRoute ] ); + /** WeatherAPI config > GET */ + $this->get('/weatherapi/config', function () { + global $response; + $this->requirePermission('weatherapi_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('weatherapi_config', 'global', 1, $user->id, 'WEATHERAPI_CONFIG', 'Successfully fetched weatherapi config'); + $response->success( + (new weatherapi())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('weatherapi_config', 'global', 1, 0, 'WEATHERAPI_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'weatherapi_config' => 'Get weatherapi config' + ] + ); + /** WeatherAPI config > POST */ + $this->post('/weatherapi/config', function () { + global $response; + $this->requirePermission('weatherapi_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('weatherapi_config', 'global', 1, $user->id, 'WEATHERAPI_CONFIG', 'Successfully updated weatherapi config'); + $response->success( + (new weatherapi())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('weatherapi_config', 'global', 1, 0, 'WEATHERAPI_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'weatherapi_config' => 'Update weatherapi config' + ] + ); + /** XLVask config > GET */ $this->get('/xlvask/config', function () { global $response; diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php new file mode 100644 index 00000000..53c8f614 --- /dev/null +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -0,0 +1,265 @@ +get('/modules/weatherapi/current', function () { + global $response; + self::requirePermission('modules_weatherapi_current'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['q']); + self::requireType('q', self::type_string()); + $result = (new weatherapi())->current(self::getParameter('q')); + (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Current weather request completed'); + $response->success($result, 200); + }, [ + 'modules_weatherapi_current' => 'Get current weather data from WeatherAPI', + ]); + + $this->get('/modules/weatherapi/forecast', function () { + global $response; + self::requirePermission('modules_weatherapi_forecast'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['q']); + self::requireType('q', self::type_string()); + $days = (int)(self::getParameter('days') ?? 1); + $result = (new weatherapi())->forecast(self::getParameter('q'), $days); + (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Forecast weather request completed'); + $response->success($result, 200); + }, [ + 'modules_weatherapi_forecast' => 'Get weather forecast data from WeatherAPI', + ]); + + $this->get('/modules/weatherapi/search', function () { + global $response; + self::requirePermission('modules_weatherapi_search'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['q']); + self::requireType('q', self::type_string()); + $result = (new weatherapi())->search(self::getParameter('q')); + (new logs_o())->add('modules_weatherapi', 'global', 1, $user->id, 'MODULES_WEATHERAPI', 'Weather location search completed'); + $response->success($result, 200); + }, [ + 'modules_weatherapi_search' => 'Search location data from WeatherAPI', + ]); + + $this->get('/departments/weather', function () { + global $response; + self::requirePermission('departments_weather_get'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + self::requireParameters(['id']); + self::requireType((int)self::getParameter('id'), self::type_int()); + self::requireMinValue((int)self::getParameter('id'), 1); + self::requireDepartmentAccess((int)self::getParameter('id')); + + $department = (new departments_o())->select((int)self::getParameter('id')); + $lat = (float)$department->latitude->value(); + $lon = (float)$department->longitude->value(); + if ($lat === 0.0 && $lon === 0.0) { + $response->error('Department does not have GPS coordinates configured', 400); + return; + } + + $opening_hours = new department_time_bookings_opening_hours_o(); + $opening_hours->selectByDepartment((int)self::getParameter('id')); + + $weather = (new weatherapi())->forecast($lat . ',' . $lon, 2); + $timeline = self::buildDepartmentWeatherTimeline((int)self::getParameter('id'), $weather, $opening_hours); + + (new logs_o())->add('modules_weatherapi', (int)self::getParameter('id'), 1, $user->id, 'DEPARTMENTS_WEATHER_GET', 'Department weather timeline fetched'); + $response->success($timeline, 200); + }, [ + 'departments_weather_get' => 'Get department weather timeline with washes and productivity status', + 'department_access_:id' => 'Access weather timeline for a specific department', + ]); + } + + /** + * @throws Exception + */ + private function buildDepartmentWeatherTimeline(int $department_id, object $forecast, department_time_bookings_opening_hours_o $opening_hours): array + { + $hourly_weather = []; + foreach (($forecast->forecast->forecastday ?? []) as $day) { + foreach (($day->hour ?? []) as $hour) { + $key = (new DateTime((string)$hour->time))->format('Y-m-d H:00'); + $hourly_weather[$key] = self::mapWeatherCondition((int)($hour->condition->code ?? 1000), (string)($hour->condition->text ?? '')); + } + } + + $entries = []; + $slot = new DateTime(date('Y-m-d H:00:00')); + $slot->add(new DateInterval('PT1H')); + + for ($i = 0; $i < 24; $i++) { + $slot_key = $slot->format('Y-m-d H:00'); + $weather = $hourly_weather[$slot_key] ?? 'mostly_clear'; + $is_open = self::isDepartmentOpenAtHour($opening_hours, $slot); + $hours = $is_open ? self::getDailyOpenHours($opening_hours, $slot) : 0; + $washes = self::countWashesForHour($department_id, $slot); + $entries[] = [ + 'time' => $slot->format('H:00'), + 'weather' => $weather, + 'washes' => $washes, + 'hours' => $hours, + 'status' => self::calculateStatus($washes, $hours), + ]; + $slot->add(new DateInterval('PT1H')); + } + + return $entries; + } + + private function calculateStatus(int $washes, int $hours): string + { + if ($hours <= 0) { + return 'unknown'; + } + + $ratio = $washes / $hours; + if ($ratio >= 0.8) { + return 'healthy'; + } + if ($ratio >= 0.4) { + return 'degraded'; + } + return 'unhealthy'; + } + + /** + * @throws Exception + */ + private function isDepartmentOpenAtHour(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): bool + { + $weekday = strtolower($date_time->format('l')); + $start = $opening_hours->{"{$weekday}_start"}->value(); + $end = $opening_hours->{"{$weekday}_end"}->value(); + if ($start === null || $end === null) { + return false; + } + + $current = $date_time->format('H:i'); + $start_hour = (new DateTime((string)$start))->format('H:i'); + $end_hour = (new DateTime((string)$end))->format('H:i'); + + return $current >= $start_hour && $current < $end_hour; + } + + /** + * @throws Exception + */ + private function getDailyOpenHours(department_time_bookings_opening_hours_o $opening_hours, DateTime $date_time): int + { + $weekday = strtolower($date_time->format('l')); + $start = $opening_hours->{"{$weekday}_start"}->value(); + $end = $opening_hours->{"{$weekday}_end"}->value(); + if ($start === null || $end === null) { + return 0; + } + + $start_dt = new DateTime((string)$start); + $end_dt = new DateTime((string)$end); + $hours = ((int)$end_dt->format('U') - (int)$start_dt->format('U')) / 3600; + + return max(0, (int)round($hours)); + } + + /** + * @throws Exception + */ + private function countWashesForHour(int $department_id, DateTime $hour_start): int + { + $start = clone $hour_start; + $end = clone $hour_start; + $end->add(new DateInterval('PT59M59S')); + + return (new orders_o())->countWashesInDateRange( + $start->format('Y-m-d H:i:s'), + $end->format('Y-m-d H:i:s'), + $department_id + ); + } + + private function mapWeatherCondition(int $code, string $text): string + { + $rain = [1063, 1150, 1153, 1180, 1183, 1186, 1189, 1192, 1195, 1240, 1243, 1246]; + $showers = [1072, 1168, 1171, 1198, 1201, 1249, 1252]; + $snow = [1066, 1069, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1255, 1258, 1261, 1264]; + $thunder = [1087, 1273, 1276, 1279, 1282]; + + if ($code === 1000) { + return 'clear'; + } + if ($code === 1003) { + return 'mostly_clear'; + } + if ($code === 1006) { + return 'partly_cloudy'; + } + if ($code === 1009) { + return 'mostly_cloudy'; + } + if ($code === 1030 || str_contains(strtolower($text), 'overcast')) { + return 'overcast'; + } + if ($code === 1135 || $code === 1147) { + return 'fog'; + } + if (in_array($code, $thunder, true)) { + return 'thunderstorm'; + } + if (in_array($code, $snow, true)) { + return 'snow'; + } + if (in_array($code, $showers, true)) { + return 'showers'; + } + if (in_array($code, $rain, true)) { + return 'rain'; + } + + return 'mostly_clear'; + } +}