diff --git a/openapi.yaml b/openapi.yaml index fb139162..5d218417 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10961,12 +10961,23 @@ paths: name: {type: string} description: {type: string} cvr: {type: integer} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option added successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' put: @@ -10984,17 +10995,30 @@ paths: required: [id] properties: id: {type: integer} - name: {type: string} - description: {type: string} - cvr: {type: integer} + name: {type: string, nullable: true} + description: {type: string, nullable: true} + cvr: {type: integer, nullable: true} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option updated successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /roles: get: @@ -11168,6 +11192,36 @@ paths: application/json: schema: {} + /superuser/department/branding: + put: + tags: + - Departments + summary: Set department branding + description: Assign an existing branding option to a department, or clear the department branding by sending a null branding_id. + operationId: setDepartmentBranding + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, branding_id] + properties: + department_id: {type: integer} + branding_id: {type: integer, nullable: true} + responses: + '200': + description: Department branding updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /superuser/department/prices: get: tags: diff --git a/services/nginx/app/modules/dynamicimages/images/machine_1.php b/services/nginx/app/modules/dynamicimages/images/machine_1.php index 2ce9eadf..646d853a 100644 --- a/services/nginx/app/modules/dynamicimages/images/machine_1.php +++ b/services/nginx/app/modules/dynamicimages/images/machine_1.php @@ -74,10 +74,9 @@ class machine_1 extends dynamicimages_image $this->drawProgramWheel(); $this->drawThumb(); $this->drawStepThumb(); - $this->drawHighlightedResetButton(); - $this->drawHighlightedButtons(); + $deferredStartButtons = $this->drawHighlightedButtonSequence(); $this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0); - $this->drawHighlightedStartButton(); + $this->drawDeferredHighlightedButtons($deferredStartButtons); $this->drawCropOutLine(true); } @@ -194,6 +193,161 @@ class machine_1 extends dynamicimages_image return false; } + private function normalizeHighlightedButtonToken(mixed $button): int|string|null + { + if (is_string($button)) { + $trimmed = trim($button); + $specialButton = strtolower($trimmed); + if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) { + return $specialButton; + } + + if ($trimmed !== '' && ctype_digit($trimmed)) { + $button = (int)$trimmed; + } + } + + if (is_int($button)) { + return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; + } + + if (is_numeric($button) && (int)$button == $button) { + $button = (int)$button; + return $button >= 0 && $button < ($this->button_rows * $this->button_columns) ? $button : null; + } + + return null; + } + + private function getOrderedHighlightedButtonTokens(): array + { + $tokens = []; + foreach ($this->highlighted_buttons as $button) { + $token = $this->normalizeHighlightedButtonToken($button); + if ($token !== null) { + $tokens[] = $token; + } + } + + return $tokens; + } + + private function getRegularButtonCoordinates(int $buttonIndex): ?array + { + if ($buttonIndex < 0 || $buttonIndex >= ($this->button_rows * $this->button_columns)) { + return null; + } + + $row = intdiv($buttonIndex, $this->button_columns); + $col = $buttonIndex % $this->button_columns; + $x = 2815 + ($col * ($this->button_highlight_size + $this->button_columns_spacing)); + $y = 1265 + ($row * ($this->button_highlight_size + $this->button_rows_spacing)); + if ($row === $this->button_rows - 1) { + $y += $this->button_last_row_spacing_buffer; + } + + return [ + 'x' => $x, + 'y' => $y, + 'size' => $this->button_highlight_size, + ]; + } + + private function getHighlightedButtonCoordinates(int|string $button): ?array + { + if ($button === self::BUTTON_RESET) { + return [ + 'x' => 1736, + 'y' => 599, + 'size' => $this->reset_button_highlight_size, + ]; + } + + if ($button === self::BUTTON_START) { + return [ + 'x' => 4965, + 'y' => 1980, + 'size' => $this->start_button_highlight_size, + ]; + } + + return is_int($button) ? $this->getRegularButtonCoordinates($button) : null; + } + + /** + * @throws \Exception + */ + private function buildHighlightedButtonDraw(int|string $button): ?array + { + $coordinates = $this->getHighlightedButtonCoordinates($button); + if ($coordinates === null) { + return null; + } + + $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); + $tmp->resize($coordinates['size'], $coordinates['size']); + $tmp = $this->drawStepCounterOnButton($tmp); + if ($this->only_generate_current_step && $this->button_counter != $this->current_step + 1) { + if (method_exists($tmp, 'clearMemoryImage')) { + $tmp->clearMemoryImage(); + } + return null; + } + + return [ + 'asset' => $tmp, + 'x' => $coordinates['x'], + 'y' => $coordinates['y'], + ]; + } + + /** + * @throws \Exception + */ + private function drawHighlightedButtonDraw(array $draw): void + { + $tmp = $draw['asset']; + $this->drawAsset($tmp, (int)$draw['x'], (int)$draw['y']); + if (method_exists($tmp, 'clearMemoryImage')) { + $tmp->clearMemoryImage(); + } + } + + /** + * @return array + * @throws \Exception + */ + public function drawHighlightedButtonSequence(): array + { + $deferredStartButtons = []; + foreach ($this->getOrderedHighlightedButtonTokens() as $button) { + $draw = $this->buildHighlightedButtonDraw($button); + if ($draw === null) { + continue; + } + + if ($button === self::BUTTON_START) { + $deferredStartButtons[] = $draw; + continue; + } + + $this->drawHighlightedButtonDraw($draw); + } + + return $deferredStartButtons; + } + + /** + * @param array $deferredStartButtons + * @throws \Exception + */ + public function drawDeferredHighlightedButtons(array $deferredStartButtons): void + { + foreach ($deferredStartButtons as $draw) { + $this->drawHighlightedButtonDraw($draw); + } + } + /** * @throws \Exception */ @@ -545,12 +699,8 @@ class machine_1 extends dynamicimages_image $rad = deg2rad($angle); $stepX = $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2); $stepY = $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2); - $tmp = new dynamicimages_asset($this->getAssetPath(self::getHighlightedAssetName())); + $tmp = new dynamicimages_asset($this->getAssetPath(self::IMAGE_BUTTON_HIGHLIGHTED_BLUE)); $tmp->resize($stepSize, $stepSize); - $tmp = $this->drawStepCounterOnButton($tmp); - if ($this->only_generate_current_step && $this->button_counter != $this->current_step +1) { - return; - } $this->drawAsset($tmp, $stepX, $stepY); // Free memory used by temporary asset image, if any if (method_exists($tmp, 'clearMemoryImage')) { diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index a3eab301..a48383b7 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -154,6 +154,80 @@ class orders_o extends db // Save the object } + /** + * Return the data needed to decide whether deletion needs explicit confirmation. + * + * @return array{ + * requires_confirmation: bool, + * protected_reasons: array, + * order_item_count: int, + * attachment_count: int, + * completed_at: mixed + * } + * @throws Exception + */ + public function getDeleteProtectionSummary(): array + { + self::requireSelected(); + + $completedAt = $this->completed_at->value(); + $orderItemCount = $this->countActiveOrderItems(); + $attachmentCount = $this->countActiveOrderAttachments(); + $protectedReasons = []; + + if ($completedAt !== null) { + $protectedReasons[] = 'completed'; + } + if ($orderItemCount > 0) { + $protectedReasons[] = 'order_items'; + } + if ($attachmentCount > 0) { + $protectedReasons[] = 'attachments'; + } + + return [ + 'requires_confirmation' => count($protectedReasons) > 0, + 'protected_reasons' => $protectedReasons, + 'order_item_count' => $orderItemCount, + 'attachment_count' => $attachmentCount, + 'completed_at' => $completedAt, + ]; + } + + /** + * @throws Exception + */ + private function countActiveOrderItems(): int + { + self::requireSelected(); + global $db; + + $orderId = (int)$this->id; + $result = $db->query("SELECT COUNT(*) AS total FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL"); + if ($result && $row = $result->fetch_assoc()) { + return (int)($row['total'] ?? 0); + } + + return 0; + } + + /** + * @throws Exception + */ + private function countActiveOrderAttachments(): int + { + self::requireSelected(); + global $db; + + $orderId = (int)$this->id; + $result = $db->query("SELECT COUNT(*) AS total FROM object_attachments WHERE object_type = 'orders' AND object_id = {$orderId} AND deleted_at IS NULL"); + if ($result && $row = $result->fetch_assoc()) { + return (int)($row['total'] ?? 0); + } + + return 0; + } + /** * @throws Exception If the order is not selected * This function is called when the order object is changed. @@ -278,6 +352,7 @@ class orders_o extends db } // Set the completed_at property to the current timestamp $this->completed_at->set(date('Y-m-d H:i:s')); + $this->setPendingHandheldIndicator(false); $washCertificateCreated = $this->completeWashCertificateIfNeeded( $operator, (string)$this->completed_at->value() diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 9ac51a0a..0a24d06c 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -3146,12 +3146,55 @@ paths: required: true schema: type: integer + - name: confirmed + in: query + required: false + description: Must be true to delete an order that is completed or has active items or attachments. + schema: + type: boolean responses: '200': description: Order deleted successfully content: application/json: schema: {} + '409': + description: Order deletion requires explicit confirmation + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: false + data: + type: object + properties: + message: + type: string + example: Order deletion requires confirmation + requires_confirmation: + type: boolean + example: true + protected_reasons: + type: array + items: + type: string + enum: [completed, order_items, attachments] + order_item_count: + type: integer + example: 2 + attachment_count: + type: integer + example: 1 + completed_at: + type: string + nullable: true + meta: + type: object + includes: + type: object '401': $ref: '#/components/responses/Unauthorized' '404': @@ -11125,12 +11168,23 @@ paths: name: {type: string} description: {type: string} cvr: {type: integer} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option added successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' put: @@ -11148,17 +11202,30 @@ paths: required: [id] properties: id: {type: integer} - name: {type: string} - description: {type: string} - cvr: {type: integer} + name: {type: string, nullable: true} + description: {type: string, nullable: true} + cvr: {type: integer, nullable: true} + address: {type: string, nullable: true} + phone_country_code: {type: integer, nullable: true} + phone: {type: integer, nullable: true} + email: {type: string, nullable: true} + website: {type: string, nullable: true} + banner: {type: string, nullable: true} + logo: {type: string, nullable: true} + favicon: {type: string, nullable: true} + signature: {type: string, nullable: true} responses: '200': description: Branding option updated successfully content: application/json: schema: {} + '400': + $ref: '#/components/responses/BadRequest' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /roles: get: @@ -11332,6 +11399,36 @@ paths: application/json: schema: {} + /superuser/department/branding: + put: + tags: + - Departments + summary: Set department branding + description: Assign an existing branding option to a department, or clear the department branding by sending a null branding_id. + operationId: setDepartmentBranding + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, branding_id] + properties: + department_id: {type: integer} + branding_id: {type: integer, nullable: true} + responses: + '200': + description: Department branding updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /superuser/department/prices: get: tags: diff --git a/services/nginx/app/routes/BrandingRoute.php b/services/nginx/app/routes/BrandingRoute.php index b8983e6a..d959a102 100644 --- a/services/nginx/app/routes/BrandingRoute.php +++ b/services/nginx/app/routes/BrandingRoute.php @@ -11,6 +11,87 @@ class BrandingRoute { use route_t; + private const BRANDING_FIELDS = [ + 'name' => 'string', + 'description' => 'string', + 'cvr' => 'int', + 'address' => 'string', + 'phone_country_code' => 'int', + 'phone' => 'int', + 'email' => 'string', + 'website' => 'string', + 'banner' => 'string', + 'logo' => 'string', + 'favicon' => 'string', + 'signature' => 'string', + ]; + + private function readBrandingPayload(array $requiredFields = []): array + { + global $response; + + $payload = []; + foreach (self::BRANDING_FIELDS as $field => $type) { + if (!self::isParametersSet([$field])) { + continue; + } + + $value = self::getParameter($field); + if ($value === '') { + $value = null; + } + + if ($value === null) { + if (in_array($field, $requiredFields, true)) { + $response->error($field . ' is required', 400); + } + $payload[$field] = null; + continue; + } + + if ($type === 'int') { + if (is_int($value)) { + $payload[$field] = $value; + continue; + } + + if (is_string($value) && preg_match('/^-?\d+$/', $value) === 1) { + $payload[$field] = (int)$value; + continue; + } + + $response->error($field . ' must be an integer', 400); + } + + if (!is_string($value)) { + $response->error($field . ' must be a string', 400); + } + + $payload[$field] = $value; + } + + foreach ($requiredFields as $requiredField) { + if (!array_key_exists($requiredField, $payload)) { + $response->error($requiredField . ' is required', 400); + } + } + + return $payload; + } + + private function applyBrandingPayload(branding_o $branding, array $payload): void + { + foreach ($payload as $field => $value) { + if (!array_key_exists($field, self::BRANDING_FIELDS) || !property_exists($branding, $field)) { + continue; + } + + $branding->{$field}->set($value); + } + + $branding->objectChanged(); + } + public function run(): void { $this->get('/branding', function () { @@ -35,7 +116,7 @@ class BrandingRoute if ($branding->exists()) { // Return the object as an array $response->success( - (new branding_o())->select(self::getParameter('id'))->__toString() + $branding->asArray() ); } else { // Log the incident @@ -77,25 +158,15 @@ class BrandingRoute if ($user) { // Check if the required parameters are set self::requireParameters(['name', 'description', 'cvr']); - // Check if the parameters are of the correct type - self::requireType(self::getParameter('name'), self::TYPE_STRING()); - self::requireType(self::getParameter('description'), self::TYPE_STRING()); - self::requireType(self::getParameter('cvr'), self::TYPE_INT()); // Create the object $branding = new branding_o(); // Add the object - $branding->add( - [ - 'name' => self::getParameter('name'), - 'description' => self::getParameter('description'), - 'cvr' => self::getParameter('cvr') - ] - ); + $branding->add($this->readBrandingPayload(['name', 'description', 'cvr'])); // Log the incident (new logs_o())->add('branding', 'global', 1, $user->id, 'ADD_BRANDING_OPTION', 'User added a branding option'); // Return the object $response->success( - $branding->__toString() + $branding->asArray() ); } else { // Log the incident @@ -125,41 +196,18 @@ class BrandingRoute $branding = new branding_o(); // Select the object $branding->select(self::getParameter('id')); - // Check what the user wants to edit + if (!$branding->exists()) { + $response->error('Invalid id', 400); + } - // Option name - if (self::isParametersSet(['name'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('name'), self::TYPE_STRING()); - // Set the name - $branding->name->set( - (string)self::getParameter('name') - ); - } - // Option description - if (self::isParametersSet(['description'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('description'), self::TYPE_STRING()); - // Set the description - $branding->description->set( - (string)self::getParameter('description') - ); - } - // Option cvr - if (self::isParametersSet(['cvr'])) { - // Check if the parameters are of the correct type - self::requireType(self::getParameter('cvr'), self::TYPE_INT()); - // Set the cvr value - $branding->cvr->set( - (int)self::getParameter('cvr') - ); - } + $payload = $this->readBrandingPayload(); + $this->applyBrandingPayload($branding, $payload); // Log the incident (new logs_o())->add('branding', 'global', 1, $user->id, 'EDIT_BRANDING_OPTION', 'User edited a branding option'); // Return the object $response->success( - $branding->__toString() + $branding->asArray() ); } else { // Log the incident @@ -173,4 +221,4 @@ class BrandingRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/departmentLanesRoute.php b/services/nginx/app/routes/departmentLanesRoute.php index 841a2640..7737dc3e 100644 --- a/services/nginx/app/routes/departmentLanesRoute.php +++ b/services/nginx/app/routes/departmentLanesRoute.php @@ -135,7 +135,7 @@ class departmentLanesRoute * Query parameters: * - department (int, required) * - lane (int, required) - * - buttons (array|json|csv, optional) → highlighted button IDs (0-indexed) + * - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", or "start" * - current_step (int >= 0, optional) → current click/step indicator * - only_current_step (bool/int, optional) → if true, only draw current step highlight * - vehicle_type (int|null, optional) → normalized but currently not used by machine_1 diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index cf02a459..8245c256 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -218,15 +218,48 @@ class ordersRoute // Get the current order $order = (new orders_o())->getOrderById((int)$id); // Check if the order exists - if (!$order->exists()) { + if (!isset($order->id) || (int)$order->id < 1 || !$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((int)$order->department_id->value()); + $confirmed = filter_var($this->fromRequest('confirmed'), FILTER_VALIDATE_BOOLEAN) === true; + $deleteProtection = $order->getDeleteProtectionSummary(); + if ($deleteProtection['requires_confirmation'] && !$confirmed) { + (new logs_o())->add( + 'orders', + $order->department_id->value(), + 1, + $user->id, + 'DELETE_ORDER_CONFIRMATION_REQUIRED', + 'Order deletion requires confirmation (ID: ' . $id + . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) + . '; order_items: ' . $deleteProtection['order_item_count'] + . '; attachments: ' . $deleteProtection['attachment_count'] . ')' + ); + $response->error([ + 'message' => 'Order deletion requires confirmation', + ...$deleteProtection, + ], 409); + } // Delete the order $order->delete(); // Log the incident - (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')'); + if ($deleteProtection['requires_confirmation']) { + (new logs_o())->add( + 'orders', + $order->department_id->value(), + 1, + $user->id, + 'DELETE_ORDER_CONFIRMED', + 'Successfully deleted a protected order after confirmation (ID: ' . $id + . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) + . '; order_items: ' . $deleteProtection['order_item_count'] + . '; attachments: ' . $deleteProtection['attachment_count'] . ')' + ); + } else { + (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')'); + } // Return a success message $response->success(['message' => 'Order deleted successfully']); } else { diff --git a/services/nginx/app/routes/superuserDepartmentRoute.php b/services/nginx/app/routes/superuserDepartmentRoute.php index 675fedfb..ff76ddc9 100644 --- a/services/nginx/app/routes/superuserDepartmentRoute.php +++ b/services/nginx/app/routes/superuserDepartmentRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use objects\branding_o; use objects\department_variables_o; use objects\departments_o; use objects\logs_o; @@ -50,6 +51,63 @@ class superuserDepartmentRoute 'superuser_fetch_department' => 'Fetch department' ]); + $this->put('/superuser/department/branding', function () { + global $response; + $this->requirePermission('superuser_set_department_branding'); + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + self::requireParameters(['department_id', 'branding_id']); + + $departmentId = self::getParameter('department_id'); + if (!is_int($departmentId) && !(is_string($departmentId) && preg_match('/^\d+$/', $departmentId) === 1)) { + $response->error('Department ID must be a number', 400); + } + $departmentId = (int)$departmentId; + if ($departmentId <= 0) { + $response->error('Department ID must be a positive number', 400); + } + + $department = (new departments_o())->selectId($departmentId); + if (!$department->exists()) { + $response->error('Department not found', 404); + } + + $brandingId = self::getParameter('branding_id'); + if ($brandingId === '' || $brandingId === null || $brandingId === 0 || $brandingId === '0') { + $department->branding->set(null); + } else { + if (!is_int($brandingId) && !(is_string($brandingId) && preg_match('/^\d+$/', $brandingId) === 1)) { + $response->error('Branding ID must be a number', 400); + } + + $brandingId = (int)$brandingId; + if ($brandingId <= 0) { + $response->error('Branding ID must be a positive number', 400); + } + + $branding = (new branding_o())->select($brandingId); + if (!$branding->exists()) { + $response->error('Branding not found', 404); + } + + $department->branding->set($brandingId); + } + + $department->objectChanged(); + (new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_SET_DEPARTMENT_BRANDING', 'Successfully set department branding'); + + $response->success( + (new departments_o())->getDepartmentById($departmentId, true) + ); + }, [ + 'superuser_set_department_branding' => 'Set department branding' + ]); + $this->post('/superuser/department/prices', function () { // Require the user to be logged in global $response; @@ -237,4 +295,4 @@ class superuserDepartmentRoute ]); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Api/OrdersApiTest.php b/services/nginx/app/tests/Api/OrdersApiTest.php index 27bb0873..ad0ea5cf 100644 --- a/services/nginx/app/tests/Api/OrdersApiTest.php +++ b/services/nginx/app/tests/Api/OrdersApiTest.php @@ -798,6 +798,86 @@ it('deletes orders through the real endpoint', function (): void { expect($row['deleted_at'] ?? null)->not->toBeNull(); }); +it('requires explicit confirmation before deleting protected orders', function (): void { + api_test_covers('DELETE /orders', 'failure'); + api_test_covers('DELETE /orders', 'happy'); + + $department = api_fixtures()->createDepartment(); + $customer = api_fixtures()->createUser(); + $cashier = api_fixtures()->createUser(['display_name' => 'Protected Delete Cashier']); + $session = api_fixtures()->createUserSession([ + 'delete_order', + 'department_access_' . $department['id'], + ]); + + $cases = [ + 'completed' => function () use ($department, $customer, $cashier): array { + return api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + 'completed_at' => date('Y-m-d H:i:s'), + ]); + }, + 'order_items' => function () use ($department, $customer, $cashier): array { + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + ]); + api_fixtures()->createOrderItem([ + 'order_id' => $order['id'], + 'product_id' => 53, + 'cashier_id' => $cashier['id'], + 'quantity' => 1, + ]); + return $order; + }, + 'attachments' => function () use ($department, $customer, $cashier): array { + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $department['id'], + ]); + api_fixtures()->createOrderAttachment([ + 'order_id' => $order['id'], + ]); + return $order; + }, + ]; + + foreach ($cases as $expectedReason => $createProtectedOrder) { + $order = $createProtectedOrder(); + + $unconfirmed = api_client()->delete('/orders', [ + 'id' => $order['id'], + ], $session['headers']); + + $unconfirmed + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Order deletion requires confirmation'); + + expect($unconfirmed->data()['requires_confirmation'] ?? null)->toBeTrue(); + expect($unconfirmed->data()['protected_reasons'] ?? [])->toContain($expectedReason); + expect(api_fixtures()->fetchRowById('orders', (int)$order['id'])['deleted_at'] ?? null)->toBeNull(); + + $confirmed = api_client()->delete('/orders', [ + 'id' => $order['id'], + 'confirmed' => 'true', + ], $session['headers']); + + $confirmed + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Order deleted successfully'); + + expect(api_fixtures()->fetchRowById('orders', (int)$order['id'])['deleted_at'] ?? null)->not->toBeNull(); + } +}); + it('rejects invalid order delete requests', function (): void { api_test_covers('DELETE /orders', 'failure'); @@ -820,8 +900,8 @@ it('rejects invalid order delete requests', function (): void { ], $session['headers']); $missingOrder - ->assertStatus(500) + ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) - ->assertMessageContains('must not be accessed before initialization'); + ->assertMessage('Order not found'); }); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index d39ffdce..8af7d043 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -16,6 +16,10 @@ return [ 'PUT /orders', 'PUT /numberplatescanners', 'DELETE /orders', + 'GET /branding', + 'POST /branding', + 'PUT /branding', + 'PUT /superuser/department/branding', 'POST /bird/voice/calls/webhook/inbound', ], 'manual_operations' => [ diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index bf95af75..2bf4ad02 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -141,6 +141,32 @@ final class ApiFixtures return ['id' => $departmentId]; } + /** + * @param array $attributes + * @return array + */ + public function createBranding(array $attributes = []): array + { + $brandingId = $this->insertRow('branding', [ + 'name' => (string)($attributes['name'] ?? ('API Brand ' . $this->uniqueSuffix())), + 'description' => (string)($attributes['description'] ?? 'API branding'), + 'cvr' => (int)($attributes['cvr'] ?? 41004355), + 'address' => $attributes['address'] ?? null, + 'phone_country_code' => $attributes['phone_country_code'] ?? null, + 'phone' => $attributes['phone'] ?? null, + 'email' => $attributes['email'] ?? null, + 'website' => $attributes['website'] ?? null, + 'banner' => $attributes['banner'] ?? null, + 'logo' => $attributes['logo'] ?? null, + 'favicon' => $attributes['favicon'] ?? null, + 'signature' => $attributes['signature'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('branding', $brandingId)); + + return array_merge(['id' => $brandingId], $this->fetchRowById('branding', $brandingId) ?? []); + } + /** * @param array $attributes * @return array @@ -729,6 +755,34 @@ final class ApiFixtures ]; } + /** + * @param array $attributes + * @return array + */ + public function createOrderAttachment(array $attributes): array + { + $orderId = (int)($attributes['order_id'] ?? 0); + if ($orderId <= 0) { + throw new RuntimeException('Order attachments require order_id.'); + } + + $attachmentId = $this->insertRow('object_attachments', [ + 'object_type' => 'orders', + 'object_id' => $orderId, + 'content' => $attributes['content'] ?? '{"document":"api-test.pdf","other":"api-test.pdf"}', + 'created_at' => $attributes['created_at'] ?? $this->now(), + 'updated_at' => $attributes['updated_at'] ?? $this->now(), + 'deleted_at' => $attributes['deleted_at'] ?? null, + ]); + + $this->cleanup->add(fn() => $this->deleteById('object_attachments', $attachmentId)); + + return [ + 'id' => $attachmentId, + 'order_id' => $orderId, + ]; + } + /** * @param array $attributes * @return array diff --git a/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php index a1fb4015..017bd0c6 100644 --- a/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php +++ b/services/nginx/app/tests/Unit/DynamicImages/DepartmentLaneDynamicImageRouteTest.php @@ -1,11 +1,72 @@ not->toBeFalse(); - expect($content)->toContain("isRequestParameterSet('dynamic_image_id')"); - expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')"); - expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)"); - expect($content)->toContain("switch (\$dynamic_image_id)"); -}); + if (!class_exists('classes\\object_property')) { + class object_property + { + public function __construct(...$args) {} + public function value() { return null; } + public function set($v) {} + } + } +} + +namespace traits { + if (!trait_exists('traits\\db_object_t')) { + trait db_object_t {} + } +} + +namespace { + app_require('objects/department_selfserve_tasks_o.php'); +} + +namespace { + use objects\department_selfserve_tasks_o; + + it('allows studio lane dynamic image previews to override the saved image id', function (): void { + $content = file_get_contents(app_path('routes/departmentLanesRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("isRequestParameterSet('dynamic_image_id')"); + expect($content)->toContain("\$dynamic_image_override = \$response->getRequestParameter('dynamic_image_id')"); + expect($content)->toContain("self::requireMinValue(\$dynamic_image_id, 1)"); + expect($content)->toContain("switch (\$dynamic_image_id)"); + }); + + it('accepts ordered dynamic image button tokens including reset start and zero', function (): void { + expect(department_selfserve_tasks_o::normalizeButtonsInput('["reset",0,2,"start",5]'))->toBe([ + 'reset', + 0, + 2, + 'start', + 5, + ]); + + $route = file_get_contents(app_path('routes/departmentLanesRoute.php')); + expect($route)->not->toBeFalse(); + expect($route)->toContain('ordered highlighted button IDs'); + expect($route)->toContain('"reset", or "start"'); + }); + + it('renders machine one dynamic image steps from the ordered button payload', function (): void { + $content = file_get_contents(app_path('modules/dynamicimages/images/machine_1.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('$deferredStartButtons = $this->drawHighlightedButtonSequence();'); + expect($content)->toContain('getOrderedHighlightedButtonTokens'); + expect($content)->toContain('normalizeHighlightedButtonToken'); + expect($content)->toContain('drawDeferredHighlightedButtons($deferredStartButtons)'); + + $thumbOffset = strpos($content, 'public function drawStepThumb'); + expect($thumbOffset)->not->toBeFalse(); + $thumbBody = substr($content, (int)$thumbOffset, 1800); + expect($thumbBody)->toContain('self::IMAGE_BUTTON_HIGHLIGHTED_BLUE'); + expect($thumbBody)->not->toContain('drawStepCounterOnButton'); + expect($thumbBody)->not->toContain('only_generate_current_step'); + }); +}