diff --git a/openapi.yaml b/openapi.yaml index 04e7ae15..24cefafe 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2761,6 +2761,16 @@ paths: description: Optional services enabled by this task. Items must be valid service enum names. items: $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + description: Optional dynamic image button IDs enabled by this task. + items: + type: integer + default: [] + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Optional vehicle type selection override for the machine UI. Integer >= 0 or null. responses: '200': description: Successfully added task @@ -2813,6 +2823,16 @@ paths: description: Services enabled by this task. Set to null to clear all services. items: $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + nullable: true + description: Button IDs enabled by this task. Set to null to clear all buttons. + items: + type: integer + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Vehicle type selection override. Set to null to clear. responses: '200': description: Successfully updated task @@ -6790,6 +6810,16 @@ components: items: $ref: '#/components/schemas/SelfserveLaneService' default: [] + buttons: + type: array + description: Dynamic image button IDs enabled by this task. + items: + type: integer + default: [] + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Optional vehicle type selection override for the machine UI. created_at: type: string format: date-time @@ -7068,6 +7098,10 @@ components: type: string relay_machine_id: type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 created_at: type: string format: date-time @@ -7091,6 +7125,10 @@ components: type: string relay_machine_id: type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 DepartmentLaneUpdate: type: object @@ -7109,6 +7147,10 @@ components: type: string relay_machine_id: type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 Product: type: object diff --git a/services/nginx/app/objects/department_lanes_o.php b/services/nginx/app/objects/department_lanes_o.php index 4feb4a36..82da6d2a 100644 --- a/services/nginx/app/objects/department_lanes_o.php +++ b/services/nginx/app/objects/department_lanes_o.php @@ -17,6 +17,7 @@ class department_lanes_o extends db public object_property $relay_in_id; // The Shelly relay for the entrance port (if applicable) public object_property $relay_out_id; // The Shelly relay for the exit port (if applicable) public object_property $relay_machine_id; // The Shelly relay for the machine (if applicable) + public object_property $dynamic_image_id; // The dynamic image id for the lane (if applicable) public object_property $created_at; public object_property $updated_at; public object_property $deleted_at; @@ -42,10 +43,11 @@ class department_lanes_o extends db * @param string|null $relay_in_id The Shelly relay for the entrance port (if applicable) * @param string|null $relay_out_id The Shelly relay for the exit port (if applicable) * @param string|null $relay_machine_id The Shelly relay for the machine (if applicable) + * @param int|null $dynamic_image_id The dynamic image id for the lane (if applicable) * @return department_lanes_o * @throws Exception If the object was not created successfully */ - public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null): department_lanes_o + public function add(int $department, string $name, string $relay_in_id = null, string $relay_out_id = null, string $relay_machine_id = null, int $dynamic_image_id = null): department_lanes_o { global /** @var db $db */ $db; @@ -61,6 +63,12 @@ class department_lanes_o extends db if (!is_null($relay_machine_id)) { $relay_machine_id = $db->escape_string($relay_machine_id); } + if (!is_null($dynamic_image_id)) { + $dynamic_image_id = (int)$dynamic_image_id; + if ($dynamic_image_id <= 0) { + throw new Exception('dynamic_image_id must be a positive integer'); + } + } // Add the object $tmp_id = self::add_object([ 'department' => $department, @@ -68,6 +76,7 @@ class department_lanes_o extends db ...(!is_null($relay_in_id) ? ['relay_in_id' => $relay_in_id] : []), // If the relay_in_id is null, it will be set to null in the database ...(!is_null($relay_out_id) ? ['relay_out_id' => $relay_out_id] : []), // If the relay_out_id is null, it will be set to null in the database ...(!is_null($relay_machine_id) ? ['relay_machine_id' => $relay_machine_id] : []), // If the relay_machine_id is null, it will be set to null in the database + ...(!is_null($dynamic_image_id) ? ['dynamic_image_id' => $dynamic_image_id] : []), // If the dynamic_image_id is null, it will be set to null in the database ]); $this->id = $tmp_id; self::getObjectProperties(); @@ -82,6 +91,7 @@ class department_lanes_o extends db $this->relay_in_id = new object_property($this->table, $this->id, 'relay_in_id', 'string', false); $this->relay_out_id = new object_property($this->table, $this->id, 'relay_out_id', 'string', false); $this->relay_machine_id = new object_property($this->table, $this->id, 'relay_machine_id', 'string', false); + $this->dynamic_image_id = new object_property($this->table, $this->id, 'dynamic_image_id', 'int', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); @@ -101,6 +111,7 @@ class department_lanes_o extends db 'relay_in_id' => (string)$this->relay_in_id->value(), 'relay_out_id' => (string)$this->relay_out_id->value(), 'relay_machine_id' => (string)$this->relay_machine_id->value(), + 'dynamic_image_id' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_image_id->value()), // Status of the lane 'status' => (string)$this->getLaneStatus()->name, // Timestamps diff --git a/services/nginx/app/objects/department_selfserve_tasks_o.php b/services/nginx/app/objects/department_selfserve_tasks_o.php index 651b074e..307f9513 100644 --- a/services/nginx/app/objects/department_selfserve_tasks_o.php +++ b/services/nginx/app/objects/department_selfserve_tasks_o.php @@ -21,6 +21,8 @@ class department_selfserve_tasks_o extends db public object_property $description; // The task description public object_property $order_priority; // The order priority of the task (lower numbers are shown first) public object_property $services; // The services that the task enables (json), this is used to enable machine wash. + public object_property $buttons; // The buttons on the departments machine dynamic image that should be enabled by this task (json array of button ids) + public object_property $dynamic_images_vehicle_type; // The vehicle type selection override on the machine, used by dynamicimages - int or null if not applicable. public object_property $created_at; public object_property $updated_at; public object_property $deleted_at; @@ -56,11 +58,13 @@ class department_selfserve_tasks_o extends db * @param string $task The task text * @param string $description The task description * @param int $order_priority The order priority of the task (lower numbers are shown first) - * @param selfserve_lane_services[]|null $services The services that the task enables (json), this is used to enable machine wash. + * @param selfserve_lane_services[]|string[]|null $services The services that the task enables (stored as JSON array of service names). May be an array of enum cases or names. + * @param array|string|null $buttons Optional buttons on the department's machine dynamic image to be enabled by this task (stored as JSON array of button IDs). Accepts array of ints or a parsable string/JSON. + * @param int|null $dynamic_images_vehicle_type Optional vehicle type selection override for the machine UI. Integer >= 0 or null. * @return department_selfserve_tasks_o * @throws Exception If the object was not created successfully */ - public function add(int $department, int $lane, int $product, int|null $condition_id, string $task, string $description, int $order_priority = 0, ?array $services = null): self + public function add(int $department, int $lane, int $product, int|null $condition_id, string $task, string $description, int $order_priority = 0, ?array $services = null, array|string|null $buttons = null, int|null $dynamic_images_vehicle_type = null): self { global /** @var db $db */ $db; @@ -101,6 +105,20 @@ class department_selfserve_tasks_o extends db throw new Exception("Invalid service type"); } } + // Normalize/validate buttons + $buttons_ids = null; + if (!is_null($buttons)) { + $buttons_ids = self::normalizeButtonsInput($buttons); + } + + // Normalize/validate dynamic_images_vehicle_type (nullable, integer >= 0) + if (!is_null($dynamic_images_vehicle_type)) { + $dynamic_images_vehicle_type = (int)$dynamic_images_vehicle_type; + if ($dynamic_images_vehicle_type < 0) { + throw new Exception('dynamic_images_vehicle_type must be an integer >= 0'); + } + } + // Add the object $tmp_id = self::add_object([ 'department' => $department, @@ -111,6 +129,8 @@ class department_selfserve_tasks_o extends db 'description' => $description, 'order_priority' => $order_priority, 'services' => $services_names, + 'buttons' => $buttons_ids, + ...(!is_null($dynamic_images_vehicle_type) ? ['dynamic_images_vehicle_type' => $dynamic_images_vehicle_type] : []), ]); $this->id = $tmp_id; self::getObjectProperties(); @@ -129,6 +149,8 @@ class department_selfserve_tasks_o extends db $this->description = new object_property($this->table, $this->id, 'description', 'string', false); $this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false); $this->services = new object_property($this->table, $this->id, 'services', 'json', false); + $this->buttons = new object_property($this->table, $this->id, 'buttons', 'json', false); + $this->dynamic_images_vehicle_type = new object_property($this->table, $this->id, 'dynamic_images_vehicle_type', 'int', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); @@ -151,9 +173,90 @@ class department_selfserve_tasks_o extends db 'description' => (string)$this->description->value(), 'order_priority' => (int)$this->order_priority->value(), 'services' => (array)$this->services->value(), + 'buttons' => (array)$this->buttons->value(), + 'dynamic_images_vehicle_type' => (function($v){ return $v === null ? null : (int)$v; })($this->dynamic_images_vehicle_type->value()), // Timestamps 'created_at' => (string)$this->created_at->value(), 'updated_at' => (string)$this->updated_at->value(), ]; } + + /** + * Normalize input for dynamic_images_vehicle_type into a nullable non-negative integer. + * Accepts int, string (numeric), null, or empty string (treated as null). + * @param mixed $input + * @return int|null + * @throws Exception on invalid format or negative values + */ + public static function normalizeVehicleTypeInput(mixed $input): ?int + { + if ($input === null) { + return null; + } + if (is_string($input)) { + $trim = trim($input); + if ($trim === '' || strtolower($trim) === 'null') { + return null; + } + if (ctype_digit($trim)) { + $val = (int)$trim; + } elseif (is_numeric($trim) && (int)$trim == $trim) { + $val = (int)$trim; + } else { + throw new Exception('Invalid dynamic_images_vehicle_type value'); + } + } elseif (is_int($input)) { + $val = $input; + } else { + throw new Exception('Invalid dynamic_images_vehicle_type value'); + } + if ($val < 0) { + throw new Exception('dynamic_images_vehicle_type must be >= 0'); + } + return $val; + } + /** + * Normalize mixed input for buttons into an array of integer IDs (>= 0). + * Accepts: + * - array of ints/strings + * - JSON array string + * - comma-separated string + * @param mixed $input + * @return array + * @throws Exception + */ + public static function normalizeButtonsInput(mixed $input): array + { + $raw = $input; + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $raw = $decoded; + } else { + $raw = array_filter(array_map(function ($s) { return trim((string)$s); }, explode(',', $raw)), fn($s) => $s !== ''); + } + } + if (!is_array($raw)) { + throw new Exception('Invalid format for buttons. Expected array, JSON array, or comma-separated string of integers.'); + } + $ids = []; + foreach ($raw as $btn) { + if (is_int($btn)) { + $val = $btn; + } elseif (is_string($btn) && ctype_digit($btn)) { + $val = (int)$btn; + } elseif (is_numeric($btn) && (int)$btn == $btn) { + $val = (int)$btn; + } else { + throw new Exception('Invalid button id: ' . (is_scalar($btn) ? (string)$btn : gettype($btn))); + } + if ($val < 0) { + throw new Exception('Button id must be >= 0: ' . $val); + } + $ids[] = $val; + } + // de-duplicate while preserving order + $ids = array_values(array_unique($ids)); + return $ids; + } } \ No newline at end of file diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index cece3bf1..9f5eee7d 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -52,6 +52,7 @@ class departmentLanesRoute 'relay_in_id', 'relay_out_id', 'relay_machine_id', + 'dynamic_image_id', ]) ->listObjectsWithPaginationIfSet( function ($department_lane) use ($user) { @@ -91,11 +92,18 @@ class departmentLanesRoute $relay_in_id = $response->getRequestParameter('relay_in_id') ?? null; $relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; $relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; + $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; + if ($dynamic_image_id !== null) { + $did = (int)$dynamic_image_id; + $this->requireType($did, $this->type_int()); + $this->requireMinValue($did, 1); + $dynamic_image_id = $did; + } // Remove spaces from the relay_in_id and relay_out_id // Check if the required fields are set if ($name && $department) { // Add the department lane - (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id); + (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $dynamic_image_id); // Return a success message $response->success('Department lane added'); } else { @@ -133,6 +141,7 @@ class departmentLanesRoute $relay_in_id = $response->getRequestParameter('relay_in_id') ?? null; $relay_out_id = $response->getRequestParameter('relay_out_id') ?? null; $relay_machine_id = $response->getRequestParameter('relay_machine_id') ?? null; + $dynamic_image_id = $response->getRequestParameter('dynamic_image_id') ?? null; // Check what fields are set self::requireParameters(['id']); @@ -159,6 +168,17 @@ class departmentLanesRoute if (self::isParametersSet(['relay_machine_id'])) { $department_lane->relay_machine_id->set((string)$relay_machine_id); } + if (self::isParametersSet(['dynamic_image_id'])) { + $param = $response->getRequestParameter('dynamic_image_id'); + if ($param === null || $param === '' || (is_string($param) && strtolower($param) === 'null')) { + $department_lane->dynamic_image_id->nullify(); + } else { + $did = (int)$param; + $this->requireType($did, $this->type_int()); + $this->requireMinValue($did, 1); + $department_lane->dynamic_image_id->set($did); + } + } // Return a success message $response->success('Department lane updated'); } else { diff --git a/services/nginx/app/routes/departmentSelfserveTasksRoute.php b/services/nginx/app/routes/departmentSelfserveTasksRoute.php index 5c5ce73f..6280354c 100644 --- a/services/nginx/app/routes/departmentSelfserveTasksRoute.php +++ b/services/nginx/app/routes/departmentSelfserveTasksRoute.php @@ -145,6 +145,28 @@ class departmentSelfserveTasksRoute } } + // Optional buttons + $buttons_param = $response->isRequestParameterSet('buttons') ? $response->getRequestParameter('buttons') : null; + $buttons_ids = null; + if (!is_null($buttons_param)) { + try { + $buttons_ids = department_selfserve_tasks_o::normalizeButtonsInput($buttons_param); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } + + // Optional dynamic_images_vehicle_type + $vehicle_type_param = $response->isRequestParameterSet('dynamic_images_vehicle_type') ? $response->getRequestParameter('dynamic_images_vehicle_type') : null; + $vehicle_type = null; + if ($vehicle_type_param !== null) { + try { + $vehicle_type = department_selfserve_tasks_o::normalizeVehicleTypeInput($vehicle_type_param); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } + if (!$department || !$lane || !$product || !$task || !$description) { $response->error('Missing required fields', 400); } @@ -164,6 +186,8 @@ class departmentSelfserveTasksRoute $description, $order_priority, $services_enums, + $buttons_ids, + $vehicle_type, ); (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'ADD_TASK', 'User added a department self-serve task: ' . $task); $response->success($task_o->asArray()); @@ -262,6 +286,36 @@ class departmentSelfserveTasksRoute } } + if (self::isParametersSet(['buttons'])) { + $param = self::getParameter('buttons'); + if ($param === null) { + // Explicitly clear buttons + $task_o->buttons->set(null); + } else { + try { + $ids = department_selfserve_tasks_o::normalizeButtonsInput($param); + $task_o->buttons->set($ids); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } + } + + // Optional dynamic_images_vehicle_type update + if (self::isParametersSet(['dynamic_images_vehicle_type'])) { + $param = self::getParameter('dynamic_images_vehicle_type'); + if ($param === null) { + $task_o->dynamic_images_vehicle_type->set(null); + } else { + try { + $val = department_selfserve_tasks_o::normalizeVehicleTypeInput($param); + $task_o->dynamic_images_vehicle_type->set($val); + } catch (\Exception $e) { + $response->error($e->getMessage(), 400); + } + } + } + (new logs_o())->add('department_selfserve_tasks', 'global', 1, $user->id, 'EDIT_TASK', 'User updated department self-serve task ID: ' . $id); $response->success($task_o->asArray()); } else { diff --git a/services/nginx/app/tests/lanes/DepartmentLaneDynamicImageIdTest.php b/services/nginx/app/tests/lanes/DepartmentLaneDynamicImageIdTest.php new file mode 100644 index 00000000..45a266a3 --- /dev/null +++ b/services/nginx/app/tests/lanes/DepartmentLaneDynamicImageIdTest.php @@ -0,0 +1,58 @@ +structure(); // ensure table is set +$lane->id = -1; // enable fake value mode in object_property +$lane->getObjectProperties(); + +// Set fake values +$lane->department->set(7); +$lane->name->set('Test Lane'); +$lane->relay_in_id->set('shelly-relay-in-001'); +$lane->relay_out_id->set('shelly-relay-out-001'); +$lane->relay_machine_id->set('shelly-relay-machine-001'); +$lane->dynamic_image_id->set(42); +$lane->created_at->set('2026-01-01 00:00:00'); +$lane->updated_at->set('2026-01-02 00:00:00'); + +// Serialize and validate +$arr = $lane->asArray(); + +assert_true(array_key_exists('dynamic_image_id', $arr), 'dynamic_image_id is present in lane serialization'); +assert_true($arr['dynamic_image_id'] === 42, 'dynamic_image_id matches the set integer value'); + +// Some basic sanity checks to ensure no regressions +assert_true($arr['department'] === 7, 'department matches'); +assert_true($arr['name'] === 'Test Lane', 'name matches'); + +echo "DepartmentLaneDynamicImageIdTest completed.\n"; diff --git a/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php new file mode 100644 index 00000000..158c7fc3 --- /dev/null +++ b/services/nginx/app/tests/selfserve/ButtonsNormalizationTest.php @@ -0,0 +1,81 @@ +getMessage()); + } + + // 2) JSON string input with duplicates and zero + try { + $result = department_selfserve_tasks_o::normalizeButtonsInput('[4,5,5,0]'); + if ($result === [4,5,0]) { + ok('JSON input normalized with duplicates removed'); + } else { + fail('JSON input normalization mismatch: ' . json_encode($result)); + } + } catch (\Exception $e) { + fail('JSON input threw unexpectedly: ' . $e->getMessage()); + } + + // 3) CSV string input + try { + $result = department_selfserve_tasks_o::normalizeButtonsInput('6, 7 ,8'); + if ($result === [6,7,8]) { + ok('CSV input normalized correctly'); + } else { + fail('CSV input normalization mismatch: ' . json_encode($result)); + } + } catch (\Exception $e) { + fail('CSV input threw unexpectedly: ' . $e->getMessage()); + } + + // 4) Invalid input should throw + $thrown = false; + try { + department_selfserve_tasks_o::normalizeButtonsInput('["a", 2]'); + } catch (\Exception $e) { + $thrown = true; + ok('Invalid input correctly threw exception: ' . $e->getMessage()); + } + if (!$thrown) { + fail('Expected exception for invalid input, but none was thrown'); + } + + echo "\nButtonsNormalizationTest completed.\n"; +} diff --git a/services/nginx/app/tests/selfserve/DynamicImagesVehicleTypeNormalizationTest.php b/services/nginx/app/tests/selfserve/DynamicImagesVehicleTypeNormalizationTest.php new file mode 100644 index 00000000..09add410 --- /dev/null +++ b/services/nginx/app/tests/selfserve/DynamicImagesVehicleTypeNormalizationTest.php @@ -0,0 +1,81 @@ +getMessage()); } + + // 2) Numeric string input + try { + $v = department_selfserve_tasks_o::normalizeVehicleTypeInput('4'); + if ($v === 4) { ok('Numeric string input accepted'); } else { fail('Numeric string mismatch: ' . var_export($v, true)); } + } catch (\Exception $e) { fail('Numeric string input threw: ' . $e->getMessage()); } + + // 3) Trimmed numeric string with spaces + try { + $v = department_selfserve_tasks_o::normalizeVehicleTypeInput(' 5 '); + if ($v === 5) { ok('Trimmed numeric string input accepted'); } else { fail('Trimmed numeric string mismatch: ' . var_export($v, true)); } + } catch (\Exception $e) { fail('Trimmed numeric string input threw: ' . $e->getMessage()); } + + // 4) Null input -> null + try { + $v = department_selfserve_tasks_o::normalizeVehicleTypeInput(null); + if ($v === null) { ok('Null input returns null'); } else { fail('Null input mismatch: ' . var_export($v, true)); } + } catch (\Exception $e) { fail('Null input threw: ' . $e->getMessage()); } + + // 5) Empty string -> null + try { + $v = department_selfserve_tasks_o::normalizeVehicleTypeInput(''); + if ($v === null) { ok('Empty string treated as null'); } else { fail('Empty string mismatch: ' . var_export($v, true)); } + } catch (\Exception $e) { fail('Empty string threw: ' . $e->getMessage()); } + + // 6) Negative value should throw + $thrown = false; + try { + department_selfserve_tasks_o::normalizeVehicleTypeInput(-1); + } catch (\Exception $e) { + $thrown = true; ok('Negative value correctly threw: ' . $e->getMessage()); + } + if (!$thrown) { fail('Expected exception for negative value, but none was thrown'); } + + // 7) Non-numeric string should throw + $thrown = false; + try { + department_selfserve_tasks_o::normalizeVehicleTypeInput('abc'); + } catch (\Exception $e) { + $thrown = true; ok('Non-numeric string correctly threw: ' . $e->getMessage()); + } + if (!$thrown) { fail('Expected exception for non-numeric string, but none was thrown'); } + + echo "\nDynamicImagesVehicleTypeNormalizationTest completed.\n"; +}