From 48a74ad34d542715121b379a2eb261dc55f7353e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 24 Mar 2026 10:33:17 +0100 Subject: [PATCH] Add Workfeed module with routes, configurations, and API integration handling for employees, shifts, and departments. Update OpenAPI spec and include unit tests for endpoint wiring and filtering logic. --- openapi.yaml | 246 +++++++++++++++++ services/nginx/app/classes/workfeed.php | 261 ++++++++++++++++++ services/nginx/app/interfaces/workfeed_i.php | 16 ++ .../workfeed/config/workfeed_api_key_c.php | 29 ++ .../workfeed/config/workfeed_api_url_c.php | 29 ++ .../workfeed/config/workfeed_enabled_c.php | 29 ++ .../nginx/app/modules/workfeed/workfeed_c.php | 35 +++ .../nginx/app/routes/moduleConfigRoute.php | 40 +++ .../nginx/app/routes/moduleWorkfeedRoute.php | 135 +++++++++ .../WorkfeedConfigRouteWiringTest.php | 12 + .../Workfeed/WorkfeedRouteHelpersTest.php | 37 +++ .../Unit/Workfeed/WorkfeedRouteWiringTest.php | 23 ++ 12 files changed, 892 insertions(+) create mode 100644 services/nginx/app/classes/workfeed.php create mode 100644 services/nginx/app/interfaces/workfeed_i.php create mode 100644 services/nginx/app/modules/workfeed/config/workfeed_api_key_c.php create mode 100644 services/nginx/app/modules/workfeed/config/workfeed_api_url_c.php create mode 100644 services/nginx/app/modules/workfeed/config/workfeed_enabled_c.php create mode 100644 services/nginx/app/modules/workfeed/workfeed_c.php create mode 100644 services/nginx/app/routes/moduleWorkfeedRoute.php create mode 100644 services/nginx/app/tests/Unit/Workfeed/WorkfeedConfigRouteWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteHelpersTest.php create mode 100644 services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteWiringTest.php diff --git a/openapi.yaml b/openapi.yaml index 6970b740..87276c99 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7677,6 +7677,193 @@ paths: schema: $ref: '#/components/schemas/WeatherApiObjectResponse' + /modules/workfeed/employees: + get: + tags: + - Modules + summary: List Workfeed employees + description: List employees from Workfeed with optional filtering and pagination + operationId: workfeedListEmployees + parameters: + - name: search + in: query + required: false + schema: + type: string + description: Search term for employee lookup + - name: departmentId + in: query + required: false + schema: + type: string + description: Filter by Workfeed department identifier + - name: includeInactive + in: query + required: false + schema: + oneOf: + - type: boolean + - type: string + description: Include inactive employees (`true`/`false`) + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page size limit + - name: cursor + in: query + required: false + schema: + type: string + description: Cursor for paginated navigation + responses: + '200': + description: Workfeed employees retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedObjectResponse' + + /modules/workfeed/employees/{id}: + get: + tags: + - Modules + summary: Get Workfeed employee + description: Retrieve a single Workfeed employee by identifier + operationId: workfeedGetEmployee + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed employee identifier + responses: + '200': + description: Workfeed employee retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedObjectResponse' + + /modules/workfeed/shifts: + get: + tags: + - Modules + summary: List Workfeed shifts + description: List Workfeed shifts with optional timeframe and pagination filters + operationId: workfeedListShifts + parameters: + - name: from + in: query + required: false + schema: + type: string + description: Shift start range filter (provider-specific date/time format) + - name: to + in: query + required: false + schema: + type: string + description: Shift end range filter (provider-specific date/time format) + - name: departmentId + in: query + required: false + schema: + type: string + description: Filter by Workfeed department identifier + - name: employeeId + in: query + required: false + schema: + type: string + description: Filter by Workfeed employee identifier + - name: status + in: query + required: false + schema: + type: string + description: Filter by shift status + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page size limit + - name: cursor + in: query + required: false + schema: + type: string + description: Cursor for paginated navigation + responses: + '200': + description: Workfeed shifts retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedObjectResponse' + + /modules/workfeed/shifts/{id}: + get: + tags: + - Modules + summary: Get Workfeed shift + description: Retrieve a single Workfeed shift by identifier + operationId: workfeedGetShift + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed shift identifier + responses: + '200': + description: Workfeed shift retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedObjectResponse' + + /modules/workfeed/departments: + get: + tags: + - Modules + summary: List Workfeed departments + description: List departments from Workfeed with optional filtering and pagination + operationId: workfeedListDepartments + parameters: + - name: search + in: query + required: false + schema: + type: string + description: Search term for department lookup + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + description: Page size limit + - name: cursor + in: query + required: false + schema: + type: string + description: Cursor for paginated navigation + responses: + '200': + description: Workfeed departments retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedObjectResponse' + /departments/weather: get: tags: @@ -8441,6 +8628,35 @@ paths: schema: $ref: '#/components/schemas/ModuleConfigUpdateResponse' + /workfeed/config: + get: + tags: [Config] + summary: Get Workfeed config + operationId: getWorkfeedConfig + responses: + '200': + description: Workfeed configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedConfigListResponse' + post: + tags: [Config] + summary: Update Workfeed config + operationId: updateWorkfeedConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Workfeed configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + /gatewayapi/config: get: tags: [Config] @@ -9890,6 +10106,18 @@ components: - type: string required: [module, variable, type, value] + WorkfeedConfigEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [enabled, api_url, api_key] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + GatewayApiConfigEntry: type: object properties: @@ -10084,6 +10312,14 @@ components: data: { type: array, items: { $ref: '#/components/schemas/WeatherApiConfigEntry' } } required: [data] + WorkfeedConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WorkfeedConfigEntry' } } + required: [data] + GatewayApiConfigListResponse: allOf: - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' @@ -10203,6 +10439,16 @@ components: additionalProperties: true required: [data] + WorkfeedObjectResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: object + additionalProperties: true + required: [data] + DepartmentWeatherTimelineResponse: allOf: - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' diff --git a/services/nginx/app/classes/workfeed.php b/services/nginx/app/classes/workfeed.php new file mode 100644 index 00000000..e2d9f184 --- /dev/null +++ b/services/nginx/app/classes/workfeed.php @@ -0,0 +1,261 @@ +config = new workfeed_c(); + } + + /** + * @throws Exception + */ + public function requireModuleEnabled(): void + { + if (!$this->config->enabled->isTrue()) { + throw new Exception('The workfeed module is not enabled.'); + } + } + + /** + * @throws Exception + */ + public function listEmployees(array $filters = []): object + { + return $this->sendApiRequest('GET', '/employees', $this->filterAllowed($filters, [ + 'search', + 'departmentId', + 'includeInactive', + 'limit', + 'cursor', + ])); + } + + /** + * @throws Exception + */ + public function getEmployee(string $id): object + { + $this->requireValidIdentifier($id, 'employee id'); + + return $this->sendApiRequest('GET', '/employees/' . rawurlencode($id)); + } + + /** + * @throws Exception + */ + public function listShifts(array $filters = []): object + { + return $this->sendApiRequest('GET', '/shifts', $this->filterAllowed($filters, [ + 'from', + 'to', + 'departmentId', + 'employeeId', + 'status', + 'limit', + 'cursor', + ])); + } + + /** + * @throws Exception + */ + public function getShift(string $id): object + { + $this->requireValidIdentifier($id, 'shift id'); + + return $this->sendApiRequest('GET', '/shifts/' . rawurlencode($id)); + } + + /** + * @throws Exception + */ + public function listDepartments(array $filters = []): object + { + return $this->sendApiRequest('GET', '/departments', $this->filterAllowed($filters, [ + 'search', + 'limit', + 'cursor', + ])); + } + + /** + * @throws Exception + */ + private function sendApiRequest(string $method, string $path, array $query = []): object + { + $this->requireModuleEnabled(); + $this->requireConfiguredApiUrl(); + $this->requireConfiguredApiKey(); + + $url = $this->buildUrl($this->config->api_url->getVariableValue(), $path, $query); + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . trim((string)$this->config->api_key->getVariableValue()), + ]; + + return $this->executeJsonRequest($method, $url, $headers); + } + + /** + * @throws Exception + */ + private function executeJsonRequest(string $method, string $url, array $headers): object + { + $response = $this->executeRequest($method, $url, $headers); + $decoded = json_decode($response['body']); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('Invalid JSON response from Workfeed (HTTP ' . $response['status'] . ').'); + } + + if ($response['status'] >= 400) { + throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'Workfeed API request failed')); + } + + if (is_object($decoded)) { + return $decoded; + } + + return (object)[ + 'data' => $decoded, + ]; + } + + /** + * @throws Exception + */ + private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array + { + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($curl); + $statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + + if ($error !== '') { + throw new Exception('cURL request to Workfeed failed: ' . $error); + } + + if ($responseBody === false) { + throw new Exception('Workfeed request returned an empty response.'); + } + + return [ + 'status' => $statusCode, + 'body' => (string)$responseBody, + ]; + } + + private function buildUrl(string $baseUrl, string $path = '', array $query = []): string + { + $url = rtrim(trim($baseUrl), '/'); + if ($path !== '') { + $url .= '/' . ltrim($path, '/'); + } + + $query = array_filter($query, static function (mixed $value): bool { + return $value !== null && $value !== ''; + }); + + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + return $url; + } + + /** + * @throws Exception + */ + private function requireConfiguredApiUrl(): void + { + $url = trim((string)$this->config->api_url->getVariableValue()); + if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) { + throw new Exception('Invalid Workfeed API URL configured.'); + } + } + + /** + * @throws Exception + */ + private function requireConfiguredApiKey(): void + { + if (trim((string)$this->config->api_key->getVariableValue()) === '') { + throw new Exception('Invalid Workfeed API key configured.'); + } + } + + private function normalizeUrlForValidation(string $url): string + { + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + return 'https://' . ltrim($url, '/'); + } + + /** + * @throws Exception + */ + private function requireValidIdentifier(string $value, string $label): void + { + if (trim($value) === '') { + throw new Exception('Invalid ' . $label . '.'); + } + } + + private function filterAllowed(array $filters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($filters as $key => $value) { + if (isset($allowed[$key])) { + $filtered[$key] = $value; + } + } + + return $filtered; + } + + private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string + { + if (is_object($decoded)) { + if (isset($decoded->message) && is_string($decoded->message)) { + return $decoded->message; + } + if (isset($decoded->error) && is_string($decoded->error)) { + return $decoded->error; + } + } + + if (is_string($decoded) && trim($decoded) !== '') { + return $decoded; + } + + return $fallback . ' (HTTP ' . $statusCode . ').'; + } +} diff --git a/services/nginx/app/interfaces/workfeed_i.php b/services/nginx/app/interfaces/workfeed_i.php new file mode 100644 index 00000000..aa734f51 --- /dev/null +++ b/services/nginx/app/interfaces/workfeed_i.php @@ -0,0 +1,16 @@ +setupConfig('workfeed'); + $this->allowUpdate([ + workfeed_enabled_c::class, + workfeed_api_url_c::class, + workfeed_api_key_c::class, + ]); + + $this->enabled = new workfeed_enabled_c(); + $this->api_url = new workfeed_api_url_c(); + $this->api_key = new workfeed_api_key_c(); + } +} diff --git a/services/nginx/app/routes/moduleConfigRoute.php b/services/nginx/app/routes/moduleConfigRoute.php index ce510f13..beb67981 100644 --- a/services/nginx/app/routes/moduleConfigRoute.php +++ b/services/nginx/app/routes/moduleConfigRoute.php @@ -14,6 +14,7 @@ use classes\response; use classes\router; use classes\stripe; use classes\weatherapi; +use classes\workfeed; use objects\logs_o; use traits\route_t; @@ -492,6 +493,45 @@ class moduleConfigRoute ] ); + /** Workfeed config > GET */ + $this->get('/workfeed/config', function () { + global $response; + $this->requirePermission('modules_workfeed_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('workfeed_config', 'global', 1, $user->id, 'WORKFEED_CONFIG', 'Successfully fetched workfeed config'); + $response->success( + (new workfeed())->config->getConfigRequest() + ); + } else { + (new logs_o())->add('workfeed_config', 'global', 1, 0, 'WORKFEED_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_workfeed_config' => 'Get workfeed config' + ] + ); + /** Workfeed config > POST */ + $this->post('/workfeed/config', function () { + global $response; + $this->requirePermission('modules_workfeed_config'); + $user = (new authentication())->get_user(); + if ($user) { + (new logs_o())->add('workfeed_config', 'global', 1, $user->id, 'WORKFEED_CONFIG', 'Successfully updated workfeed config'); + $response->success( + (new workfeed())->config->postConfigRequest() + ); + } else { + (new logs_o())->add('workfeed_config', 'global', 1, 0, 'WORKFEED_CONFIG', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + }, + [ + 'modules_workfeed_config' => 'Update workfeed config' + ] + ); + /** XLVask config > GET */ $this->get('/xlvask/config', function () { global $response; diff --git a/services/nginx/app/routes/moduleWorkfeedRoute.php b/services/nginx/app/routes/moduleWorkfeedRoute.php new file mode 100644 index 00000000..f6af4e11 --- /dev/null +++ b/services/nginx/app/routes/moduleWorkfeedRoute.php @@ -0,0 +1,135 @@ +get('/modules/workfeed/employees', function () { + global $response; + self::requirePermission('modules_workfeed_employees_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'search', + 'departmentId', + 'includeInactive', + 'limit', + 'cursor', + ]); + + $result = (new workfeed())->listEmployees($filters); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_EMPLOYEES_LIST', 'Listed Workfeed employees'); + $response->success($result, 200); + }, [ + 'modules_workfeed_employees_view' => 'List Workfeed employees', + ]); + + $this->get('/modules/workfeed/employees/{id}', function () { + global $response; + self::requirePermission('modules_workfeed_employees_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new workfeed())->getEmployee((string)$this->fromRoute('id')); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_EMPLOYEE_GET', 'Fetched Workfeed employee'); + $response->success($result, 200); + }, [ + 'modules_workfeed_employees_view' => 'Get a specific Workfeed employee', + ]); + + $this->get('/modules/workfeed/shifts', function () { + global $response; + self::requirePermission('modules_workfeed_shifts_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'from', + 'to', + 'departmentId', + 'employeeId', + 'status', + 'limit', + 'cursor', + ]); + + $result = (new workfeed())->listShifts($filters); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_SHIFTS_LIST', 'Listed Workfeed shifts'); + $response->success($result, 200); + }, [ + 'modules_workfeed_shifts_view' => 'List Workfeed shifts', + ]); + + $this->get('/modules/workfeed/shifts/{id}', function () { + global $response; + self::requirePermission('modules_workfeed_shifts_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $result = (new workfeed())->getShift((string)$this->fromRoute('id')); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_SHIFT_GET', 'Fetched Workfeed shift'); + $response->success($result, 200); + }, [ + 'modules_workfeed_shifts_view' => 'Get a specific Workfeed shift', + ]); + + $this->get('/modules/workfeed/departments', function () { + global $response; + self::requirePermission('modules_workfeed_departments_view'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $filters = $this->filterRequestParameters($_GET, [ + 'search', + 'limit', + 'cursor', + ]); + + $result = (new workfeed())->listDepartments($filters); + (new logs_o())->add('modules_workfeed', 'global', 1, $user->id, 'MODULES_WORKFEED_DEPARTMENTS_LIST', 'Listed Workfeed departments'); + $response->success($result, 200); + }, [ + 'modules_workfeed_departments_view' => 'List Workfeed departments', + ]); + } + + private function filterRequestParameters(array $parameters, array $allowedKeys): array + { + $allowed = array_flip($allowedKeys); + $filtered = []; + + foreach ($parameters as $key => $value) { + if (isset($allowed[$key]) && $value !== '' && $value !== null) { + $filtered[$key] = $value; + } + } + + return $filtered; + } +} diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedConfigRouteWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedConfigRouteWiringTest.php new file mode 100644 index 00000000..73073d55 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedConfigRouteWiringTest.php @@ -0,0 +1,12 @@ +not->toBeFalse(); + expect($content)->toContain('/workfeed/config'); + expect($content)->toContain("requirePermission('modules_workfeed_config')"); + expect($content)->toContain("(new workfeed())->config->getConfigRequest()"); + expect($content)->toContain("(new workfeed())->config->postConfigRequest()"); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteHelpersTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteHelpersTest.php new file mode 100644 index 00000000..91bea779 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteHelpersTest.php @@ -0,0 +1,37 @@ +getMethod($method); + $target->setAccessible(true); + + return $target->invokeArgs($route, $args); +} + +it('filters request parameters down to the allowed workfeed query keys', function (): void { + $_SERVER['REQUEST_URI'] = '/modules/workfeed/shifts'; + $route = new moduleWorkfeedRoute(); + + $filtered = workfeed_route_invoke_private($route, 'filterRequestParameters', [[ + 'from' => '2026-03-24T00:00:00Z', + 'to' => '2026-03-25T00:00:00Z', + 'departmentId' => '10', + 'cursor' => '', + 'ignored' => 'value', + ], [ + 'from', + 'to', + 'departmentId', + ]]); + + expect($filtered)->toBe([ + 'from' => '2026-03-24T00:00:00Z', + 'to' => '2026-03-25T00:00:00Z', + 'departmentId' => '10', + ]); +}); diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteWiringTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteWiringTest.php new file mode 100644 index 00000000..039ea7f7 --- /dev/null +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedRouteWiringTest.php @@ -0,0 +1,23 @@ +not->toBeFalse(); + expect($content)->toContain('/modules/workfeed/employees'); + expect($content)->toContain('/modules/workfeed/employees/{id}'); + expect($content)->toContain('/modules/workfeed/departments'); + expect($content)->toContain("requirePermission('modules_workfeed_employees_view')"); + expect($content)->toContain("requirePermission('modules_workfeed_departments_view')"); +}); + +it('registers shift endpoints for the workfeed module', function (): void { + $routeFile = app_path('routes/moduleWorkfeedRoute.php'); + $content = file_get_contents($routeFile); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('/modules/workfeed/shifts'); + expect($content)->toContain('/modules/workfeed/shifts/{id}'); + expect($content)->toContain("requirePermission('modules_workfeed_shifts_view')"); +});