diff --git a/openapi.yaml b/openapi.yaml index 6cedfdae..1be1b5a5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1746,9 +1746,9 @@ paths: - Subusers summary: Create a subuser registration description: | - Creates a subuser (driver) account using a company's CVR and a phone number. Validates the - CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled - sends a setup link by SMS for the user to complete registration. + Starts a driver setup challenge using a company's CVR and a phone number. No company grant + is created until the driver proves possession of the phone by completing the SMS setup link. + The public response is uniform and never includes setup credentials or relationship state. operationId: createSubuser security: [] requestBody: @@ -1761,6 +1761,7 @@ paths: - cvr - phone_country_code - phone + - g_recaptcha_response properties: cvr: type: integer @@ -1774,24 +1775,23 @@ paths: type: integer description: Phone number (4–15 digits, no leading +) example: 12345678 + g_recaptcha_response: + type: string + description: reCAPTCHA response token responses: '200': - description: Subuser created (or pending setup) and company identified + description: Uniform driver registration acknowledgement content: application/json: schema: type: object properties: - cvr: - type: integer - example: 12345678 - customer_number: - type: integer - description: Matched e-conomic customer number - example: 1000 + message: + type: string '400': { $ref: '#/components/responses/BadRequest' } '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalServerError' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } /subusers/{id}: get: @@ -13543,8 +13543,72 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/PricingConflict' + + /limited-backoffice/departments/{departmentId}/prices: + get: + tags: + - Limited Backoffice + summary: Get explicit limited-backoffice department prices + description: Returns only explicit department prices and an opaque revision for optimistic concurrency. + operationId: getLimitedBackofficeDepartmentPrices + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Explicit department prices and current revision + content: + application/json: + schema: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + put: + tags: + - Limited Backoffice + summary: Replace explicit limited-backoffice department prices + description: Replaces the submitted explicit prices atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes. + operationId: setLimitedBackofficeDepartmentPrices + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPricesUpdateRequest' + responses: + '200': + description: Explicit department prices updated atomically + content: + application/json: + schema: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/PricingConflict' /limited-backoffice/departments/{departmentId}/customer-pricing: get: @@ -13593,7 +13657,7 @@ paths: tags: - Limited Backoffice summary: Replace limited-backoffice department customer pricing - description: Replaces the complete override set for one customer in an assigned custom-only department. + description: Replaces the complete override set atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes. operationId: setLimitedBackofficeDepartmentCustomerPricing parameters: - name: departmentId @@ -13622,7 +13686,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/PricingConflict' /superuser/department/variables: get: @@ -14397,6 +14461,14 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + PricingConflict: + description: Pricing is unavailable in the current state or `expected_revision` is stale. Stale writes return code `pricing_revision_conflict` and the current revision. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/PricingRevisionConflictResponse' Unauthorized: description: Unauthorized - Invalid or missing authentication token content: @@ -14590,6 +14662,8 @@ components: customer_number: type: integer minimum: 1 + expected_revision: + $ref: '#/components/schemas/PricingRevision' overrides: type: array items: @@ -14606,6 +14680,8 @@ components: customer_number: type: integer minimum: 1 + expected_revision: + $ref: '#/components/schemas/PricingRevision' overrides: type: array items: @@ -14636,6 +14712,7 @@ components: type: integer nullable: true minimum: 0 + description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both. DepartmentCustomerPricingOverride: allOf: @@ -14722,6 +14799,8 @@ components: type: integer display_name: type: string + revision: + $ref: '#/components/schemas/PricingRevision' overrides: type: array items: @@ -14733,6 +14812,95 @@ components: meta: type: object additionalProperties: true + + PricingRevision: + type: string + pattern: '^[a-f0-9]{64}$' + description: Opaque SHA-256 content revision. Return it as `expected_revision` on the next update. + + PricingRevisionConflictResponse: + type: object + required: [success, data] + properties: + success: + type: boolean + enum: [false] + data: + type: object + required: [message, code, current_revision] + properties: + message: + type: string + enum: [Pricing has changed. Reload and try again.] + code: + type: string + enum: [pricing_revision_conflict] + current_revision: + $ref: '#/components/schemas/PricingRevision' + meta: + type: object + additionalProperties: true + + LimitedBackofficeDepartmentPriceInput: + type: object + required: [product_id, price] + properties: + product_id: + type: integer + minimum: 1 + price: + type: integer + minimum: 0 + + LimitedBackofficeDepartmentPricesUpdateRequest: + type: object + required: [prices] + properties: + expected_revision: + $ref: '#/components/schemas/PricingRevision' + prices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPriceInput' + + LimitedBackofficeDepartmentPricesResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + department: + type: object + properties: + id: { type: integer } + name: { type: string } + description: { type: string } + custom_pricing_only: { type: boolean } + revision: + $ref: '#/components/schemas/PricingRevision' + categories: + type: array + items: + type: object + properties: + id: { type: integer } + name: { type: string } + description: { type: string } + products: + type: array + items: + type: object + properties: + id: { type: integer } + name: { type: string } + description: { type: string } + price: { type: integer } + meta: + type: object + additionalProperties: true includes: type: object additionalProperties: true diff --git a/services/nginx/app/classes/department_customer_pricing_service.php b/services/nginx/app/classes/department_customer_pricing_service.php index 337f7ca3..f4ee62ef 100644 --- a/services/nginx/app/classes/department_customer_pricing_service.php +++ b/services/nginx/app/classes/department_customer_pricing_service.php @@ -25,6 +25,7 @@ class department_customer_pricing_service 'customer' => $customer, 'overrides' => $overrides, 'categories' => $this->catalog($departmentId, $customer['id']), + 'revision' => $this->revision($overrides), ]; } @@ -52,34 +53,69 @@ class department_customer_pricing_service $normalized = $this->normalizeOverrides($departmentId, $overrides); $overrideObject = new department_customer_price_overrides_o(); - $existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']); + $expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null); + $existingOverrides = []; $normalizedKeys = []; foreach ($normalized as $override) { $normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true; } global $db; - $db->conn()->begin_transaction(); + $mysqli = $db->conn(); + $mysqli->begin_transaction(); try { - $db->query( - 'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ' - . (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id'] - ); - - foreach ($normalized as $override) { - $overrideObject->setPrice( - $departmentId, - $customer['id'], - (bool)$override['is_category'], - $override['product_or_category_id'], - (int)$override['percentage'], - $override['fixed_price'] - ); + $this->lockDepartment($departmentId); + $existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']); + $currentRevision = $this->revision($existingOverrides); + if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) { + throw $this->revisionConflict($currentRevision); } - $db->conn()->commit(); + $deleteStatement = $mysqli->prepare( + 'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ? AND `user_id` = ?' + ); + $insertStatement = $mysqli->prepare( + 'INSERT INTO `department_customer_price_overrides` + (`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`) + VALUES (?, ?, ?, ?, ?, ?)' + ); + if ($deleteStatement === false || $insertStatement === false) { + throw new \RuntimeException('Unable to prepare department customer pricing update.'); + } + + $customerId = (int)$customer['id']; + $deleteStatement->bind_param('ii', $departmentId, $customerId); + if (!$deleteStatement->execute()) { + throw new \RuntimeException('Unable to clear department customer pricing.'); + } + + foreach ($normalized as $override) { + $isCategory = (int)(bool)$override['is_category']; + $objectId = (string)$override['product_or_category_id']; + $percentage = (int)$override['percentage']; + $fixedPrice = $override['fixed_price'] === null ? null : (int)$override['fixed_price']; + $insertStatement->bind_param( + 'iiisii', + $departmentId, + $customerId, + $isCategory, + $objectId, + $percentage, + $fixedPrice + ); + if (!$insertStatement->execute()) { + throw new \RuntimeException('Unable to save department customer pricing.'); + } + } + + $deleteStatement->close(); + $insertStatement->close(); + $mysqli->commit(); + } catch (limited_backoffice_exception $exception) { + $mysqli->rollback(); + throw $exception; } catch (\Throwable) { - $db->conn()->rollback(); + $mysqli->rollback(); throw new limited_backoffice_exception('Unable to update department customer pricing.', 500); } @@ -254,6 +290,9 @@ class department_customer_pricing_service } if ($isCategory) { + if ($fixedPrice !== null) { + throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400); + } $fixedPrice = null; $objectId = (string)$objectId; if ($objectId !== 'global') { @@ -269,6 +308,14 @@ class department_customer_pricing_service } $key = $this->overrideKey($isCategory, $objectId); + if (isset($normalized[$key])) { + throw new limited_backoffice_exception('Duplicate customer price overrides are not allowed.', 400); + } + + if ($fixedPrice !== null && $percentage > 0) { + throw new limited_backoffice_exception('Choose either a discount or a fixed price.', 400); + } + $normalized[$key] = [ 'is_category' => $isCategory, 'product_or_category_id' => $objectId, @@ -313,6 +360,64 @@ class department_customer_pricing_service return ((int)$isCategory) . ':' . (string)$objectId; } + /** + * @param array> $overrides + */ + private function revision(array $overrides): string + { + $revisionRows = array_map(static fn(array $override): array => [ + 'is_category' => (bool)$override['is_category'], + 'product_or_category_id' => (string)$override['product_or_category_id'], + 'percentage' => (int)$override['percentage'], + 'fixed_price' => $override['fixed_price'] === null ? null : (int)$override['fixed_price'], + ], $overrides); + + usort($revisionRows, static function (array $left, array $right): int { + return [$left['is_category'] ? 0 : 1, $left['product_or_category_id']] + <=> [$right['is_category'] ? 0 : 1, $right['product_or_category_id']]; + }); + + return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR)); + } + + private function normalizeExpectedRevision(mixed $value): ?string + { + if ($value === null || $value === '') { + // Keep the backend-first rollout compatible with the currently deployed UI. + return null; + } + + if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) { + throw new limited_backoffice_exception('Expected revision is invalid.', 400, [ + 'message' => 'Expected revision is invalid.', + 'code' => 'pricing_revision_invalid', + ]); + } + + return $value; + } + + private function lockDepartment(int $departmentId): void + { + global $db; + + $result = $db->query( + 'SELECT `id` FROM `departments` WHERE `id` = ' . (int)$departmentId . ' FOR UPDATE' + ); + if (!$result || $result->num_rows < 1) { + throw new limited_backoffice_exception('Department not found', 404); + } + } + + private function revisionConflict(string $currentRevision): limited_backoffice_exception + { + return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [ + 'message' => 'Pricing has changed. Reload and try again.', + 'code' => 'pricing_revision_conflict', + 'current_revision' => $currentRevision, + ]); + } + private function assertDepartmentProduct(int $departmentId, int $productId): void { global $db; diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index 7b51a8d3..4554ee27 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -634,6 +634,27 @@ class limited_backoffice_service return $this->accessibleDepartmentIdsForGroup($groupId); } + /** + * Returns the authoritative department scope only when the authenticated + * user is managed by limited backoffice. A null result means the caller + * must preserve the route's existing non-limited authorization semantics. + * + * @return array|null + */ + public function managedEmployeeDepartmentIds(users_o $user): ?array + { + if (!$user->exists() || (int)$user->id <= 0) { + return null; + } + + $managedEmployee = $this->loadManagedEmployee((int)$user->id); + if ($managedEmployee === null) { + return null; + } + + return $this->decodeDepartmentIds((string)($managedEmployee['department_ids'] ?? '[]')); + } + /** * Loads department scope from an authoritative group identity rather than * from user object properties that may be backed by a stale Redis value. @@ -753,6 +774,7 @@ class limited_backoffice_service return [ 'department' => $department, 'categories' => $catalog['categories'], + 'revision' => $this->departmentPricesRevision($departmentId), ]; } @@ -804,10 +826,17 @@ class limited_backoffice_service } } + $expectedRevision = $this->normalizeExpectedPricingRevision($payload['expected_revision'] ?? null); $mysqli = $this->mysqli(); $mysqli->begin_transaction(); try { + $this->lockDepartmentForPricingUpdate($departmentId); + $currentRevision = $this->departmentPricesRevision($departmentId); + if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) { + throw $this->pricingRevisionConflict($currentRevision); + } + $deleteStatement = $mysqli->prepare( 'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?' ); @@ -820,15 +849,22 @@ class limited_backoffice_service foreach ($normalizedPrices as $productId => $price) { $deleteStatement->bind_param('ii', $departmentId, $productId); - $deleteStatement->execute(); + if (!$deleteStatement->execute()) { + throw new \RuntimeException('Unable to clear department price.'); + } $insertStatement->bind_param('iii', $departmentId, $productId, $price); - $insertStatement->execute(); + if (!$insertStatement->execute()) { + throw new \RuntimeException('Unable to save department price.'); + } } $deleteStatement->close(); $insertStatement->close(); $mysqli->commit(); + } catch (limited_backoffice_exception $exception) { + $mysqli->rollback(); + throw $exception; } catch (\Throwable $throwable) { $mysqli->rollback(); throw new limited_backoffice_exception('Unable to update department prices.', 500); @@ -1348,35 +1384,7 @@ class limited_backoffice_service } /** - * @return array{employee_id:int,login_path:string} - */ - public function createEmployeeLoginLink(users_o $manager, int $employeeId): array - { - $this->assertEmployeeLoginTarget($manager, $employeeId); - - $token = (new authentication())->create_employee_token($employeeId); - - try { - (new logs_o())->add( - 'auth', - 'global', - 1, - (int)$manager->id, - 'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK', - 'Created limited backoffice login link for employee: ' . $employeeId - ); - } catch (\Throwable) { - // Audit logging should not block login-link generation. - } - - return [ - 'employee_id' => $employeeId, - 'login_path' => '/login/qr?token=' . $token, - ]; - } - - /** - * Applies the same target and department boundary used by the legacy login-link endpoint. + * Applies the target and department boundary used by one-time login grants. */ public function assertEmployeeLoginTarget( users_o $manager, @@ -1406,6 +1414,80 @@ class limited_backoffice_service return $db->conn(); } + private function lockDepartmentForPricingUpdate(int $departmentId): void + { + $statement = $this->mysqli()->prepare( + 'SELECT `id` FROM `departments` WHERE `id` = ? FOR UPDATE' + ); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare pricing update lock.'); + } + + $statement->bind_param('i', $departmentId); + $statement->execute(); + $result = $statement->get_result(); + $exists = $result->num_rows > 0; + $statement->close(); + + if (!$exists) { + throw new limited_backoffice_exception('Department not found', 404); + } + } + + private function departmentPricesRevision(int $departmentId): string + { + global $db; + + $statement = $this->mysqli()->prepare( + 'SELECT `product_id`, `price` + FROM `product_department_prices` + WHERE `department_id` = ? + ORDER BY `product_id` ASC, `id` ASC' + ); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare department price revision.'); + } + + $statement->bind_param('i', $departmentId); + $statement->execute(); + $rows = $db->fetch_all($statement->get_result()); + $statement->close(); + + $revisionRows = array_map(static fn(array $row): array => [ + 'product_id' => (int)$row['product_id'], + 'price' => (int)$row['price'], + ], $rows); + + return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR)); + } + + private function normalizeExpectedPricingRevision(mixed $value): ?string + { + if ($value === null || $value === '') { + // Transitional compatibility for already-deployed clients. New clients + // send the revision returned by GET and receive stale-write protection. + return null; + } + + if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) { + throw new limited_backoffice_exception('Expected revision is invalid.', 400, [ + 'message' => 'Expected revision is invalid.', + 'code' => 'pricing_revision_invalid', + ]); + } + + return $value; + } + + private function pricingRevisionConflict(string $currentRevision): limited_backoffice_exception + { + return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [ + 'message' => 'Pricing has changed. Reload and try again.', + 'code' => 'pricing_revision_conflict', + 'current_revision' => $currentRevision, + ]); + } + private function tableHasColumn(string $table, string $column): bool { $cacheKey = $table . '.' . $column; diff --git a/services/nginx/app/classes/redis.php b/services/nginx/app/classes/redis.php index 898ca8e7..0f6fdd5f 100644 --- a/services/nginx/app/classes/redis.php +++ b/services/nginx/app/classes/redis.php @@ -636,6 +636,24 @@ class redis implements redis_i return $this; } + /** + * Atomically increments a fixed-window counter and assigns its TTL on the + * first increment. This avoids the GET/SET race in public abuse controls. + */ + public function incrementWithExpiration(string $key, int $seconds): int + { + if (!self::is_connected()) { + self::connect(); + } + + $count = (int)$this->redis->incr($key); + if ($count === 1) { + $this->redis->expire($key, max(1, $seconds)); + } + + return $count; + } + public function generateTemporaryCacheKey(): string { // Generate a temporary cache key diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index a550fefa..b9a87a69 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -271,12 +271,22 @@ class orders_o extends db * @param int $entries The number of last entries to return (default 10) * @return array The orders for the vehicle plate */ - public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array + public function getOrderHistoryByVehiclePlate( + string $plate, + int $entries = 10, + ?array $departmentIds = null + ): array { global $db; $plate = $db->escape_string($plate); $entries = max(1, $entries); - $sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries"; + $departmentFilter = $this->departmentScopeSql($departmentIds); + if ($departmentFilter === false) { + return []; + } + $sql = "SELECT * FROM $this->table WHERE (reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate')" + . $departmentFilter + . " ORDER BY id DESC LIMIT $entries"; $result = $db->query($sql); return $db->fetch_all($result); } @@ -622,10 +632,18 @@ class orders_o extends db * @param string $plate The vehicle plate * @return array The order history */ - public function get_vehicle_order_history(string $plate): array + public function get_vehicle_order_history(string $plate, ?array $departmentIds = null): array { global $db; - $stmt = $db->prepare("SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?) AND deleted_at IS NULL ORDER BY id DESC LIMIT 5"); + $departmentFilter = $this->departmentScopeSql($departmentIds); + if ($departmentFilter === false) { + return []; + } + $stmt = $db->prepare( + "SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?)" + . $departmentFilter + . ' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5' + ); if (!$stmt) { return []; } @@ -636,6 +654,26 @@ class orders_o extends db return $db->fetch_all($result); } + /** + * @param array|null $departmentIds + */ + private function departmentScopeSql(?array $departmentIds): string|false + { + if ($departmentIds === null) { + return ''; + } + + $departmentIds = array_values(array_unique(array_filter( + array_map('intval', $departmentIds), + static fn(int $departmentId): bool => $departmentId > 0 + ))); + if ($departmentIds === []) { + return false; + } + + return ' AND department_id IN (' . implode(',', $departmentIds) . ')'; + } + /** * @throws Exception If the order is not selected */ diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index 106faf12..9882c25f 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -1054,26 +1054,24 @@ class users_o extends db $overrides = new department_customer_price_overrides_o(); $product = (new products_o())->select((int)$product_id); - $discounts = []; - $productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id); if ($productRow !== null) { - $discounts[] = (int)$productRow['percentage']; + return (int)$productRow['percentage']; } if ((bool)$product->apply_category_discount->value()) { $categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value()); if ($categoryRow !== null) { - $discounts[] = (int)$categoryRow['percentage']; + return (int)$categoryRow['percentage']; } $globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global'); if ($globalRow !== null) { - $discounts[] = (int)$globalRow['percentage']; + return (int)$globalRow['percentage']; } } - return max([0, ...$discounts]); + return 0; } @@ -1731,6 +1729,28 @@ class users_o extends db $this->objectChanged(); } + public function hydrateRegistrationContact( + string $email, + int $phoneNumber, + int $phoneCountryCode = 45, + ?string $contactName = null + ): void { + self::requireSelected(); + + $email = trim($email); + if ($email !== '') { + $this->email->set($email); + } + $this->setPhoneNumber($phoneNumber, $phoneCountryCode); + + $contactName = trim((string)$contactName); + if ($contactName !== '') { + $this->keys->setValue('registration_contact_name', $contactName); + } + + $this->objectChanged(); + } + public function setSMSNotificationsEnabled(bool $enabled): void { self::requireSelected(); diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 813157f7..8fef737b 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -1998,9 +1998,9 @@ paths: - Subusers summary: Create a subuser registration description: | - Creates a subuser (driver) account using a company's CVR and a phone number. Validates the - CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled - sends a setup link by SMS for the user to complete registration. + Starts a driver setup challenge using a company's CVR and a phone number. No company grant + is created until the driver proves possession of the phone by completing the SMS setup link. + The public response is uniform and never includes setup credentials or relationship state. operationId: createSubuser security: [] requestBody: @@ -2013,6 +2013,7 @@ paths: - cvr - phone_country_code - phone + - g_recaptcha_response properties: cvr: type: integer @@ -2026,24 +2027,27 @@ paths: type: integer description: Phone number (4–15 digits, no leading +) example: 12345678 + g_recaptcha_response: + type: string + description: reCAPTCHA response token responses: '200': - description: Subuser created (or pending setup) and company identified + description: Uniform driver registration acknowledgement content: application/json: schema: type: object properties: - cvr: - type: integer - example: 12345678 - customer_number: - type: integer - description: Matched e-conomic customer number - example: 1000 + message: + type: string '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { $ref: '#/components/responses/Conflict' } + '429': + description: Registration rate limit exceeded '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalServerError' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } /subusers/me/verification: get: @@ -3422,7 +3426,6 @@ paths: - invoiceEmail - contactEmail - contactPhone - - contactName - g_recaptcha_response properties: cvr: @@ -3457,6 +3460,13 @@ paths: minimum: 10000000 maximum: 9999999999 example: 21754690 + contactPhoneCountryCode: + type: integer + description: Contact phone country code without a leading plus sign. Defaults to Denmark. + minimum: 1 + maximum: 999 + default: 45 + example: 45 contactName: type: string description: Contact person name @@ -14438,8 +14448,72 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/PricingConflict' + + /limited-backoffice/departments/{departmentId}/prices: + get: + tags: + - Limited Backoffice + summary: Get explicit limited-backoffice department prices + description: Returns only explicit department prices and an opaque revision for optimistic concurrency. + operationId: getLimitedBackofficeDepartmentPrices + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Explicit department prices and current revision + content: + application/json: + schema: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + put: + tags: + - Limited Backoffice + summary: Replace explicit limited-backoffice department prices + description: Replaces the submitted explicit prices atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes. + operationId: setLimitedBackofficeDepartmentPrices + parameters: + - name: departmentId + in: path + required: true + schema: + type: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPricesUpdateRequest' + responses: + '200': + description: Explicit department prices updated atomically + content: + application/json: + schema: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/PricingConflict' /limited-backoffice/departments/{departmentId}/customer-pricing: get: @@ -14488,7 +14562,7 @@ paths: tags: - Limited Backoffice summary: Replace limited-backoffice department customer pricing - description: Replaces the complete override set for one customer in an assigned custom-only department. + description: Replaces the complete override set atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes. operationId: setLimitedBackofficeDepartmentCustomerPricing parameters: - name: departmentId @@ -14517,7 +14591,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '409': - $ref: '#/components/responses/Conflict' + $ref: '#/components/responses/PricingConflict' /limited-backoffice/employees/{employeeId}/login-grants: post: @@ -15400,6 +15474,14 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + PricingConflict: + description: Pricing is unavailable in the current state or `expected_revision` is stale. Stale writes return code `pricing_revision_conflict` and the current revision. + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Error' + - $ref: '#/components/schemas/PricingRevisionConflictResponse' Unauthorized: description: Unauthorized - Invalid or missing authentication token content: @@ -15926,6 +16008,8 @@ components: customer_number: type: integer minimum: 1 + expected_revision: + $ref: '#/components/schemas/PricingRevision' overrides: type: array items: @@ -15942,6 +16026,8 @@ components: customer_number: type: integer minimum: 1 + expected_revision: + $ref: '#/components/schemas/PricingRevision' overrides: type: array items: @@ -15972,6 +16058,7 @@ components: type: integer nullable: true minimum: 0 + description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both. DepartmentCustomerPricingOverride: allOf: @@ -16058,6 +16145,8 @@ components: type: integer display_name: type: string + revision: + $ref: '#/components/schemas/PricingRevision' overrides: type: array items: @@ -16069,6 +16158,95 @@ components: meta: type: object additionalProperties: true + + PricingRevision: + type: string + pattern: '^[a-f0-9]{64}$' + description: Opaque SHA-256 content revision. Return it as `expected_revision` on the next update. + + PricingRevisionConflictResponse: + type: object + required: [success, data] + properties: + success: + type: boolean + enum: [false] + data: + type: object + required: [message, code, current_revision] + properties: + message: + type: string + enum: [Pricing has changed. Reload and try again.] + code: + type: string + enum: [pricing_revision_conflict] + current_revision: + $ref: '#/components/schemas/PricingRevision' + meta: + type: object + additionalProperties: true + + LimitedBackofficeDepartmentPriceInput: + type: object + required: [product_id, price] + properties: + product_id: + type: integer + minimum: 1 + price: + type: integer + minimum: 0 + + LimitedBackofficeDepartmentPricesUpdateRequest: + type: object + required: [prices] + properties: + expected_revision: + $ref: '#/components/schemas/PricingRevision' + prices: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/LimitedBackofficeDepartmentPriceInput' + + LimitedBackofficeDepartmentPricesResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + department: + type: object + properties: + id: { type: integer } + name: { type: string } + description: { type: string } + custom_pricing_only: { type: boolean } + revision: + $ref: '#/components/schemas/PricingRevision' + categories: + type: array + items: + type: object + properties: + id: { type: integer } + name: { type: string } + description: { type: string } + products: + type: array + items: + type: object + properties: + id: { type: integer } + name: { type: string } + description: { type: string } + price: { type: integer } + meta: + type: object + additionalProperties: true includes: type: object additionalProperties: true diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index 1d6525b0..e18b6ca7 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -32,6 +32,9 @@ class authRoute { use route_t; + private const PUBLIC_REGISTRATION_RATE_LIMIT = 5; + private const PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS = 15 * 60; + private function passkeyChallengePrincipalCacheKey(string $challengeToken): string { return 'passkey_challenge_principal:' . $challengeToken; @@ -64,6 +67,38 @@ class authRoute constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken)); } + private function requirePublicRegistrationRateLimit( + string $scope, + string $identifier, + int $limit = self::PUBLIC_REGISTRATION_RATE_LIMIT, + int $windowSeconds = self::PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS, + bool $includeClientIp = true + ): void { + global $response; + + if (!defined('redis')) { + if (PHP_SAPI === 'cli') { + return; + } + $response->error('Registration protection is temporarily unavailable.', 503); + } + + $remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown')); + $key = 'auth_public_registration_throttle:' . preg_replace('/[^a-z0-9:_-]/i', '_', $scope) . ':' . hash( + 'sha256', + ($includeClientIp ? $remoteAddress . ':' : '') . $identifier + ); + try { + $attempts = constant('redis')->incrementWithExpiration($key, $windowSeconds); + } catch (\Throwable $exception) { + error_log('[auth] public registration throttle unavailable: ' . $exception->getMessage()); + $response->error('Registration protection is temporarily unavailable.', 503); + } + if ($attempts > $limit) { + $response->error('Too many registration attempts. Please wait and try again.', 429); + } + } + private function passkeyAllowCredentials(int $userId, bool $isSubuser): array { if ($userId <= 0) { @@ -403,10 +438,10 @@ class authRoute $recaptcha = (new recaptcha())->getPublicConfig(); $response->success([ 'rate_limit' => [ - 'enabled' => false, - 'limit' => 0, - 'remaining' => 0, - 'reset' => 0, + 'enabled' => defined('redis'), + 'limit' => self::PUBLIC_REGISTRATION_RATE_LIMIT, + 'remaining' => self::PUBLIC_REGISTRATION_RATE_LIMIT, + 'reset' => self::PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS, 'warning' => null ], 'recaptcha' => $recaptcha @@ -461,6 +496,9 @@ class authRoute $contactEmail = self::getParameter('contactEmail'); $contactPhone = (int)self::getParameter('contactPhone'); $contactName = self::getParameter('contactName'); + $contactPhoneCountryCode = self::isParametersSet(['contactPhoneCountryCode']) + ? (int)self::getParameter('contactPhoneCountryCode') + : 45; $ean = null; /** * Validate @@ -475,11 +513,17 @@ class authRoute self::requireType($invoiceEmail, $this->type_string()); self::requireMinLength('invoiceEmail', 5); self::requireMaxLength('invoiceEmail', 255); + if (!filter_var((string)$invoiceEmail, FILTER_VALIDATE_EMAIL)) { + $response->error('Invalid invoice email format', 400); + } } if (self::isParametersSet(['contactEmail']) && !is_null($contactEmail)) { self::requireType($contactEmail, $this->type_string()); self::requireMinLength('contactEmail', 5); self::requireMaxLength('contactEmail', 255); + if (!filter_var((string)$contactEmail, FILTER_VALIDATE_EMAIL)) { + $response->error('Invalid contact email format', 400); + } } /** * If the contact phone is set, validate it @@ -489,6 +533,17 @@ class authRoute self::requireMinValue($contactPhone, 10000000); self::requireMaxValue($contactPhone, 9999999999); } + if ($contactPhoneCountryCode < 1 || $contactPhoneCountryCode > 999) { + $response->error('Invalid contact phone country code', 400); + } + $this->requirePublicRegistrationRateLimit('customer_ip', 'all'); + $this->requirePublicRegistrationRateLimit( + 'customer_identity', + 'cvr:' . (string)$cvr . ':phone:' . $companyPhone, + 3, + 60 * 60, + false + ); if (self::isParametersSet(['ean'])) { try { $ean = economic::normalizeCustomerEan(self::getParameter('ean')); @@ -527,7 +582,15 @@ class authRoute $response->error('Company phone number already registered', 400); } - $this->bootstrapLocalCustomerOrFail($companyPhone); + $customer = $this->bootstrapLocalCustomerOrFail($companyPhone, $matchingEconomicCustomer); + $this->hydrateRegistrationContact( + $customer, + $companyPhone, + (string)$contactEmail, + (int)$contactPhone, + $contactPhoneCountryCode, + is_scalar($contactName) ? (string)$contactName : null + ); $this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail); $response->success($matchingEconomicCustomer, 200); } @@ -592,7 +655,11 @@ class authRoute $economic, (string)$cvr, $companyPhone, - (string)$invoiceEmail + (string)$invoiceEmail, + (string)$contactEmail, + (int)$contactPhone, + $contactPhoneCountryCode, + is_scalar($contactName) ? (string)$contactName : null ); if ($recoveredCustomer !== null) { @@ -638,7 +705,15 @@ class authRoute ); } - $this->bootstrapLocalCustomerOrFail($companyPhone, $result); + $customer = $this->bootstrapLocalCustomerOrFail($companyPhone, $result); + $this->hydrateRegistrationContact( + $customer, + $companyPhone, + (string)$contactEmail, + (int)$contactPhone, + $contactPhoneCountryCode, + is_scalar($contactName) ? (string)$contactName : null + ); $this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail); $response->success($result, 201); }); @@ -964,7 +1039,11 @@ class authRoute economic $economic, string $cvr, int $customerNumber, - string $invoiceEmail + string $invoiceEmail, + string $contactEmail, + int $contactPhone, + int $contactPhoneCountryCode, + ?string $contactName ): ?object { // The upstream POST can commit before the client receives a validation/transport error. // Re-read by CVR and only recover when e-conomic confirms the requested customer number. @@ -985,7 +1064,15 @@ class authRoute return null; } - $this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer); + $customer = $this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer); + $this->hydrateRegistrationContact( + $customer, + $customerNumber, + $contactEmail, + $contactPhone, + $contactPhoneCountryCode, + $contactName + ); $this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail); return $matchingEconomicCustomer; @@ -1059,6 +1146,30 @@ class authRoute $response->error('Customer was created in e-conomic but could not be imported locally.', 500); } + private function hydrateRegistrationContact( + users_o $customer, + int $customerNumber, + string $contactEmail, + int $contactPhone, + int $contactPhoneCountryCode, + ?string $contactName + ): void { + try { + $customer->hydrateRegistrationContact( + $contactEmail, + $contactPhone, + $contactPhoneCountryCode, + $contactName + ); + } catch (\Throwable $exception) { + $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONTACT_HYDRATION_FAILED', [ + 'customerNumber' => $customerNumber, + 'message' => $exception->getMessage(), + ]); + throw $exception; + } + } + /** * @throws Exception */ @@ -1068,12 +1179,21 @@ class authRoute $jimmyEmail = 'jm@truckwash.dk'; //$infoEmail = "info@truckwash.dk"; //$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian. - $email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail); - $email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail); + foreach ([$jimmyEmail, $invoiceEmail] as $recipient) { + try { + $email->sendWelcomeEmailToCustomer($customerNumber, $recipient); + } catch (\Throwable $exception) { + $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_WELCOME_EMAIL_FAILED', [ + 'customerNumber' => $customerNumber, + 'recipientHash' => hash('sha256', strtolower(trim($recipient))), + 'message' => $exception->getMessage(), + ]); + } + } try { $email->sendNewCustomerRegistrationNotifications($customerNumber); - } catch (Exception $exception) { + } catch (\Throwable $exception) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [ 'customerNumber' => $customerNumber, 'message' => $exception->getMessage(), @@ -1082,7 +1202,7 @@ class authRoute try { (new slack())->send_customer_registration_notification($customerNumber); - } catch (Exception $exception) { + } catch (\Throwable $exception) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [ 'customerNumber' => $customerNumber, 'message' => $exception->getMessage(), diff --git a/services/nginx/app/routes/bookingsRoute.php b/services/nginx/app/routes/bookingsRoute.php index 18e4801d..66693d46 100644 --- a/services/nginx/app/routes/bookingsRoute.php +++ b/services/nginx/app/routes/bookingsRoute.php @@ -51,6 +51,10 @@ class bookingsRoute if (!$user->hasAccessToBooking((int)self::getParameter('id'))) { $response->forbidden(['list_bookings']); } + $this->requireLimitedBackofficeDepartmentAccess( + $user, + (int)$bookings_o->department->value() + ); // Return the booking $response->success( $bookings_o->asArray() @@ -68,7 +72,7 @@ class bookingsRoute }, $bookings_o->forceRestrictFilters( [ - 'department' => $user->getGroup()->getDepartments(), + 'department' => $this->effectiveDepartmentIds($user), ] ) )) diff --git a/services/nginx/app/routes/departmentDailyReportsRoute.php b/services/nginx/app/routes/departmentDailyReportsRoute.php index 5c7e94dc..285a82fc 100644 --- a/services/nginx/app/routes/departmentDailyReportsRoute.php +++ b/services/nginx/app/routes/departmentDailyReportsRoute.php @@ -65,7 +65,7 @@ class departmentDailyReportsRoute $department_daily_reports_o->forceRestrictFilters( [ // This makes sure that the user can only see orders from the departments they explicitly have access to - 'department_id' => $user->getGroup()->getDepartments(), + 'department_id' => $this->effectiveDepartmentIds($user), ] ) ) @@ -520,7 +520,7 @@ class departmentDailyReportsRoute ->listObjectsWithPaginationIfSet( fn (array $complaint): array => $repository->parseComplaint($complaint), $repository->forceRestrictFilters([ - 'department_id' => $user->getGroup()->getDepartments(), + 'department_id' => $this->effectiveDepartmentIds($user), ]) ) ); diff --git a/services/nginx/app/routes/departmentTimeBookingsRoute.php b/services/nginx/app/routes/departmentTimeBookingsRoute.php index d56f3e18..1a7a34be 100644 --- a/services/nginx/app/routes/departmentTimeBookingsRoute.php +++ b/services/nginx/app/routes/departmentTimeBookingsRoute.php @@ -56,7 +56,7 @@ class departmentTimeBookingsRoute $department_time_bookings_opening_hours->forceRestrictFilters( [ // This makes sure that the user can only see orders from the departments they explicitly have access to - 'department' => ($specific_department ?? $user->getGroup()->getDepartments()), + 'department' => ($specific_department ?? $this->effectiveDepartmentIds($user)), ] ) ); @@ -151,7 +151,7 @@ class departmentTimeBookingsRoute $department_time_bookings_types->forceRestrictFilters( [ // This makes sure that the user can only see orders from the departments they explicitly have access to - 'department' => ($specific_department ?? $user->getGroup()->getDepartments()), + 'department' => ($specific_department ?? $this->effectiveDepartmentIds($user)), ] ) ); @@ -306,7 +306,7 @@ class departmentTimeBookingsRoute $department_time_bookings_entries->forceRestrictFilters( [ // This makes sure that the user can only see orders from the departments they explicitly have access to - 'department' => ($specific_department ?? $user->getGroup()->getDepartments()), + 'department' => ($specific_department ?? $this->effectiveDepartmentIds($user)), ] ) ); @@ -433,4 +433,4 @@ class departmentTimeBookingsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index 890a734a..cdc67800 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -20,7 +20,11 @@ class departmentsRoute { use route_t; - private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): array + private function buildDepartmentListFilters( + departments_o $departments, + bool $canListArchived, + ?array $departmentScope = null + ): array { global $response; @@ -43,6 +47,9 @@ class departmentsRoute unset($filters['visible'], $filters['archived']); $filters['visible'] = 1; $filters['archived'] = $archived; + if ($departmentScope !== null) { + $filters['id'] = array_values(array_map('intval', $departmentScope)); + } return $filters; } @@ -83,17 +90,23 @@ class departmentsRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { + $departmentScope = $this->limitedBackofficeDepartmentScope($user); // Log the incident (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENTS', 'Successfully listed departments'); // Check if the id is set in the request if (self::isParametersSet(['id'])) { + $departmentId = (int)self::getParameter('id'); + $this->requireLimitedBackofficeDepartmentAccess($user, $departmentId); // Return the department $response->success( - (new departments_o())->select((int)self::getParameter('id'))->asArray([ + (new departments_o())->select($departmentId)->asArray([ 'slack_webhook' => $user->hasPermission('view_slack_webhook') ]) ); } + if ($departmentScope === []) { + $response->success([]); + } $departments_o = new departments_o(); // Return the list of departments $response->success( @@ -141,7 +154,8 @@ class departmentsRoute }, $this->buildDepartmentListFilters( $departments_o, - $user->hasPermission('superuser_fetch_department') + $user->hasPermission('superuser_fetch_department'), + $departmentScope ) ) ); diff --git a/services/nginx/app/routes/limitedBackofficeRoute.php b/services/nginx/app/routes/limitedBackofficeRoute.php index ea11024b..96d501e3 100644 --- a/services/nginx/app/routes/limitedBackofficeRoute.php +++ b/services/nginx/app/routes/limitedBackofficeRoute.php @@ -118,15 +118,12 @@ class limitedBackofficeRoute ]); $this->post('/limited-backoffice/employees/{employeeId}/login-link', function () { - $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { - $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); - $this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); - return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId')); - }); - }, [ - limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', - limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees', - ]); + global $response; + $response->error( + 'Reusable employee login links are retired. Create a short-lived one-time login grant instead.', + 410 + ); + }); $this->post('/limited-backoffice/employees/{employeeId}/login-grants', function () { $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { diff --git a/services/nginx/app/routes/moduleStripeRoute.php b/services/nginx/app/routes/moduleStripeRoute.php index 2d4a51d4..2fd80e8e 100644 --- a/services/nginx/app/routes/moduleStripeRoute.php +++ b/services/nginx/app/routes/moduleStripeRoute.php @@ -110,6 +110,10 @@ class moduleStripeRoute $order = (new orders_o())->select((int)self::fromRequest('order_id')); $order->requireSelected(); + $this->requireLimitedBackofficeDepartmentAccess( + $user, + (int)$order->department_id->value() + ); if (!$order->stripe_module_orders->exists()) { $response->success([ diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index b3c54a96..9e32474f 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -142,7 +142,7 @@ class orderBookingRoute $effectiveCustomer = self::resolveEffectiveCustomerNumber(); $forcedFilters = $object->forceRestrictFilters([ ...($has_permission_other && $user !== false ? [ - 'department' => $user->getGroup()->getDepartments() + 'department' => $this->effectiveDepartmentIds($user) ] : []), ...(!$has_permission_other && $effectiveCustomer !== null ? [ 'customer_number' => [(int)$effectiveCustomer] @@ -214,7 +214,7 @@ class orderBookingRoute if ($requestedDepartmentId !== null) { $departmentIds = [$requestedDepartmentId]; } elseif ($hasPermissionOther && $user !== false) { - $departmentIds = array_map('intval', $user->getGroup()->getDepartments()); + $departmentIds = $this->effectiveDepartmentIds($user); if ($departmentIds === []) { $response->success([ 'past' => 0, diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index 879aa9ec..e6955f84 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -111,11 +111,14 @@ class ordersRoute // Build department filter when listing as department/admin $department_ids = []; if ($has_permission_other) { - $department_ids = $user->getGroup()->getDepartments(); + $department_ids = $this->effectiveDepartmentIds($user); } if (self::isParametersSet(['show_wash_subscription'])) { if (self::getParameter('show_wash_subscription') === 'true') { - $department_ids[] = '10'; + $managedScope = $this->limitedBackofficeDepartmentScope($user); + if ($managedScope === null || in_array(10, $department_ids, true)) { + $department_ids[] = 10; + } } } @@ -418,6 +421,10 @@ class ordersRoute if (!$order->exists()) { $response->error('Order not found', 400); } + $this->requireLimitedBackofficeDepartmentAccess( + $user, + (int)$order->department_id->value() + ); // Permissions (subuser-aware) $permission_own = self::definePermission('list_own_order_attachments', subusers_permission_node_key::ORDERS_LIST); $permission_other = self::definePermission('list_order_attachments'); @@ -2019,6 +2026,10 @@ class ordersRoute if (!$order->exists()) { $response->error('Order not found', 404); } + $this->requireLimitedBackofficeDepartmentAccess( + $user, + (int)$order->department_id->value() + ); if (!$hasPermissionOther) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { diff --git a/services/nginx/app/routes/plateScansRoute.php b/services/nginx/app/routes/plateScansRoute.php index 728f3cc7..35873f48 100644 --- a/services/nginx/app/routes/plateScansRoute.php +++ b/services/nginx/app/routes/plateScansRoute.php @@ -129,7 +129,7 @@ class plateScansRoute }, $number_plate_scans->forceRestrictFilters( [ - 'department_id' => $user->getGroup()->getDepartments() + 'department_id' => $this->effectiveDepartmentIds($user) ] ) ); @@ -174,4 +174,4 @@ class plateScansRoute 'add_number_plate_scan' => 'Add a number plate scan' ]); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/potentialOrderMatchesRoute.php b/services/nginx/app/routes/potentialOrderMatchesRoute.php index 9d623bee..d2925835 100644 --- a/services/nginx/app/routes/potentialOrderMatchesRoute.php +++ b/services/nginx/app/routes/potentialOrderMatchesRoute.php @@ -63,7 +63,7 @@ class potentialOrderMatchesRoute $potentialOrderMatches->forceRestrictFilters( [ // This makes sure that the user can only see department matches that belong to their departments - 'department' => $user->getGroup()->getDepartments() + 'department' => $this->effectiveDepartmentIds($user) ] ) ) @@ -127,4 +127,4 @@ class potentialOrderMatchesRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/statisticsRoute.php b/services/nginx/app/routes/statisticsRoute.php index 17fd4204..1f220c8d 100644 --- a/services/nginx/app/routes/statisticsRoute.php +++ b/services/nginx/app/routes/statisticsRoute.php @@ -26,6 +26,7 @@ class statisticsRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { + $departmentScope = $this->limitedBackofficeDepartmentScope($user); // Log the incident (new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_BOOKINGS', 'User requested new bookings'); $statics = new statistics(); @@ -33,7 +34,7 @@ class statisticsRoute $response->success( $statics ->bookings() - ->get_new_bookings() + ->get_new_bookings($departmentScope) ->get_response() ); } else { @@ -60,6 +61,7 @@ class statisticsRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { + $departmentScope = $this->limitedBackofficeDepartmentScope($user); // Log the incident (new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_ORDERS', 'User requested new orders'); $statics = new statistics(); @@ -67,7 +69,7 @@ class statisticsRoute $response->success( $statics ->orders() - ->get_new_orders() + ->get_new_orders($departmentScope) ->get_response() ); } else { @@ -395,4 +397,4 @@ class statisticsRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 57af3b0c..ce445bf8 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -741,25 +741,36 @@ class subusersRoute return $remoteAddress !== '' ? $remoteAddress : 'unknown'; } - private function recordThrottleAttempt(string $scope, string $identifier, int $limit, int $windowSeconds): ?string - { + private function recordThrottleAttempt( + string $scope, + string $identifier, + int $limit, + int $windowSeconds, + bool $includeClientIp = true + ): ?string { global $response; if (!defined('redis')) { - return null; + if (PHP_SAPI === 'cli') { + return null; + } + $response->error('Request protection is temporarily unavailable.', 503); } $safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope); $key = 'subusers_route_throttle:' . $safeScope . ':' . hash( 'sha256', - $this->clientThrottleIp() . ':' . $identifier + ($includeClientIp ? $this->clientThrottleIp() . ':' : '') . $identifier ); - $redis = constant('redis'); - $attempts = (int)($redis->get($key) ?? '0'); - if ($attempts >= $limit) { + try { + $attempts = constant('redis')->incrementWithExpiration($key, $windowSeconds); + } catch (\Throwable $exception) { + error_log('[subusers] throttle unavailable: ' . $exception->getMessage()); + $response->error('Request protection is temporarily unavailable.', 503); + } + if ($attempts > $limit) { $response->error('Too many attempts. Please wait and try again.', 429); } - $redis->setEx($key, (string)($attempts + 1), $windowSeconds); return $key; } @@ -835,6 +846,45 @@ class subusersRoute ]; } + private function publicRegistrationPendingKey(string $setupToken): string + { + return 'subuser_public_registration_pending:' . hash('sha256', $setupToken); + } + + private function storePublicRegistrationPending(string $setupToken, int $customerNumber): void + { + global $response; + if (!defined('redis')) { + $response->error('Driver registration is temporarily unavailable.', 503); + } + + constant('redis')->setEx( + $this->publicRegistrationPendingKey($setupToken), + json_encode(['customer_number' => $customerNumber], JSON_THROW_ON_ERROR), + 48 * 60 * 60 + ); + } + + private function getPublicRegistrationPending(string $setupToken): ?array + { + if (!defined('redis')) { + return null; + } + $raw = constant('redis')->get($this->publicRegistrationPendingKey($setupToken)); + if (!is_string($raw) || $raw === '') { + return null; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + private function clearPublicRegistrationPending(string $setupToken): void + { + if (defined('redis')) { + constant('redis')->delete($this->publicRegistrationPendingKey($setupToken)); + } + } + private function deliverSms(?string $destination, string $message): array { if ($destination === null || trim($destination) === '') { @@ -1730,6 +1780,124 @@ class subusersRoute ]); } + private function registerPublicSubuser(): void + { + global $db, $response; + + $this->requireRecaptcha(); + self::requireParameters(['cvr', 'phone_country_code', 'phone']); + + $cvr = (int)self::getParameter('cvr'); + $phoneCountryCode = (int)self::getParameter('phone_country_code'); + $phone = (int)self::getParameter('phone'); + self::requireType($cvr, self::type_int()); + self::requireMinLength('cvr', 8); + self::requireMaxLength('cvr', 8); + self::requireType($phoneCountryCode, self::type_int()); + self::requireMinLength('phone_country_code', 1); + self::requireMaxLength('phone_country_code', 3); + self::requireType($phone, self::type_int()); + self::requireMinLength('phone', 4); + self::requireMaxLength('phone', 15); + + $this->recordThrottleAttempt( + 'public_registration_ip', + 'all', + 5, + 15 * 60 + ); + $this->recordThrottleAttempt( + 'public_registration_identity', + 'cvr:' . $cvr . ':phone:' . $phoneCountryCode . ':' . $phone, + 3, + 60 * 60, + false + ); + + $results = (new economic())->customers->customers->search([ + 'corporateIdentificationNumber' => (string)$cvr, + ], [ + 'skipPages' => 0, + 'pageSize' => 1000, + ])->collection; + if (!is_array($results) || count($results) === 0) { + $response->error('Customer not found', 404); + } + + $customerNumber = (int)($results[0]->customerNumber ?? 0); + if ($customerNumber <= 0) { + $response->error('Customer not found', 404); + } + $companyUser = (new users_o())->getUserByCustomerNumber($customerNumber); + $companyUser->requireSelected(); + + $lockName = 'subuser-public-registration:' . hash( + 'sha256', + $phoneCountryCode . ':' . $phone + ); + $lockStatement = $db->conn->prepare('SELECT GET_LOCK(?, 5) AS `acquired`'); + if ($lockStatement === false) { + $response->error('Unable to start driver registration', 503); + } + $lockStatement->bind_param('s', $lockName); + $lockStatement->execute(); + $lockRow = $lockStatement->get_result()->fetch_assoc() ?: []; + $lockStatement->close(); + if ((int)($lockRow['acquired'] ?? 0) !== 1) { + $response->error('Driver registration is already being processed. Please try again.', 409); + } + + $subuser = null; + try { + $db->conn->begin_transaction(); + $subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($subuser !== null) { + $this->rejectBlockedSubuser((int)$subuser->id); + } else { + $subuser = (new subusers_o())->add( + null, + null, + null, + null, + $phoneCountryCode, + $phone + ); + } + $db->conn->commit(); + } catch (\Throwable $exception) { + $db->conn->rollback(); + error_log('[subusers] public registration failed: ' . $exception->getMessage()); + $response->error('Failed to create driver registration', 500); + } finally { + $releaseStatement = $db->conn->prepare('SELECT RELEASE_LOCK(?)'); + if ($releaseStatement !== false) { + $releaseStatement->bind_param('s', $lockName); + $releaseStatement->execute(); + $releaseStatement->close(); + } + } + + if (!$subuser instanceof subusers_o) { + $response->error('Failed to create driver registration', 500); + } + + if ($subuser->requiresSetup()) { + $issuedInvite = $this->issueSetupInvite($subuser); + if (($issuedInvite['delivery']['status'] ?? null) !== 'sent') { + $response->error('Driver registration was saved, but the setup SMS could not be sent. Please try again.', 503); + } + $setupToken = (string)($issuedInvite['setup_token'] ?? ''); + if ($setupToken === '') { + $response->error('Driver registration is temporarily unavailable.', 503); + } + $this->storePublicRegistrationPending($setupToken, $customerNumber); + } + + $response->success([ + 'message' => 'If the driver can be registered, setup instructions have been sent.', + ]); + } + public function run(): void { subusers_schema_bootstrap::ensureTables(); @@ -2016,78 +2184,7 @@ class subusersRoute ]); $this->post('/subusers', function () { - global /** @var response $response */ - $response; - self::requireParameters([ - 'cvr', - 'phone_country_code', - 'phone' - ]); - - $cvr = (int)self::getParameter('cvr'); - $phone_country_code =(int) self::getParameter('phone_country_code'); - $phone = (int)self::getParameter('phone'); - /** Validation */ - // Check if the CVR number is valid (8 digits) - self::requireType($cvr, self::type_int()); - self::requireMinLength('cvr', 8); - self::requireMaxLength('cvr', 8); - // Check if the phone country code is valid (1-3 digits) - self::requireType($phone_country_code, self::type_int()); - self::requireMinLength('phone_country_code', 1); - self::requireMaxLength('phone_country_code', 3); - // Check if the phone number is valid (4-15 digits) - self::requireType($phone, self::type_int()); - self::requireMinLength('phone', 4); - self::requireMaxLength('phone', 15); - // Check if the CVR is registered to a company in the database - $economic = new economic(); - $results = $economic->customers->customers->search([ - 'corporateIdentificationNumber' => (string)$cvr, - ],[ - 'skipPages' => 0, - 'pageSize' => 1000, // Since the limit is 1000, we need to set the page size to 1000. - ])->collection; - if (count($results) === 0) { - $response->error('Customer not found', 404); - } - /** At this point, we know that the CVR is valid and that the company exists in the database. */ - $company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber); - $company_user->requireSelected(); - /** We can now check if a subuser already exists with the same phone number. */ - $subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone); - if ($subuser !== null) { - $response->error('Account already exists with this phone number', 400); - } - /** We can now create the subuser and send a request to the company user to link the subuser to the company. */ - $subuser = (new subusers_o())->add( - null, - null, - null, - null, - (int)$phone_country_code, - (int)$phone - ); - $invite = $this->issueSetupInvite($subuser); - // Add the grant request - $subuser_grants_o = new subuser_grants_o(); - try { - $grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null); - } catch (Exception $e) { - $response->error('Failed to add subuser grant', 500); - } - $customerNotification = $this->notifyCustomerOfGrantRequest( - $grant, - $subuser, - (int)$results[0]->customerNumber - ); - $response->success([ - 'cvr' => $cvr, - 'customer_number' => $results[0]->customerNumber, - 'invite' => $invite, - 'customer_notification' => $customerNotification, - ]); - // Code for creating a new subuser would go here + $this->registerPublicSubuser(); }); $this->get('/subusers/setup', function () { // Require the user to be logged in @@ -2158,8 +2255,40 @@ class subusersRoute ...(!empty($email) ? ['email' => $email] : []), ]; $subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $setupUpdates)); + $pendingRegistration = $this->getPublicRegistrationPending($token); + if ($pendingRegistration !== null) { + $customerNumber = (int)($pendingRegistration['customer_number'] ?? 0); + if ($customerNumber <= 0) { + $response->error('Driver registration request is invalid.', 400); + } + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer( + (int)$subuser->id, + $customerNumber, + true + ); + if ($grant === null) { + $grant = (new subuser_grants_o())->add( + $customerNumber, + (int)$subuser->id, + false, + null + ); + } + $notification = $this->notifyCustomerOfGrantRequest( + $grant, + $subuser, + $customerNumber + ); + if (($notification['status'] ?? null) !== 'sent') { + $response->error( + 'Driver setup was saved, but the customer notification could not be sent. Please try again.', + 503 + ); + } + } // Invalidate the setup token $subuser->invalidateSetupToken($token); + $this->clearPublicRegistrationPending($token); $this->clearThrottleAttempt($setupThrottleKey); $response->success(['message' => 'Complete registration successful']); } catch (Exception $e) { @@ -2916,82 +3045,7 @@ class subusersRoute ]); // Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me $this->post('/subusers/me', function () { - global /** @var response $response */ - $response; - // Required input - self::requireParameters([ - 'cvr', - 'phone_country_code', - 'phone' - ]); - - $cvr = (int)self::getParameter('cvr'); - $phone_country_code = (int)self::getParameter('phone_country_code'); - $phone = (int)self::getParameter('phone'); - - // Validation - self::requireType($cvr, self::type_int()); - self::requireMinLength('cvr', 8); - self::requireMaxLength('cvr', 8); - self::requireType($phone_country_code, self::type_int()); - self::requireMinLength('phone_country_code', 1); - self::requireMaxLength('phone_country_code', 3); - self::requireType($phone, self::type_int()); - self::requireMinLength('phone', 4); - self::requireMaxLength('phone', 15); - - // Look up company by CVR in e-conomic - $economic = new economic(); - $results = $economic->customers->customers->search([ - 'corporateIdentificationNumber' => (string)$cvr, - ], [ - 'skipPages' => 0, - 'pageSize' => 1000, - ])->collection; - if (count($results) === 0) { - $response->error('Customer not found', 404); - } - - // Ensure company user exists (sanity) and phone is not already registered - $company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber); - $company_user->requireSelected(); - - $existing = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone); - if ($existing !== null) { - $response->error('Account already exists with this phone number', 400); - } - - // Create subuser skeleton - $subuser = (new subusers_o())->add( - null, - null, - null, - null, - (int)$phone_country_code, - (int)$phone - ); - - $invite = $this->issueSetupInvite($subuser); - - // Create a pending grant request for the company - $subuser_grants_o = new subuser_grants_o(); - try { - $grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null); - } catch (Exception $e) { - $response->error('Failed to add subuser grant', 500); - } - - $customerNotification = $this->notifyCustomerOfGrantRequest( - $grant, - $subuser, - (int)$results[0]->customerNumber - ); - $response->success([ - 'cvr' => $cvr, - 'customer_number' => $results[0]->customerNumber, - 'invite' => $invite, - 'customer_notification' => $customerNotification, - ]); + $this->registerPublicSubuser(); }); } } diff --git a/services/nginx/app/routes/systemSearchRoute.php b/services/nginx/app/routes/systemSearchRoute.php index f4b4eacb..27ad95c8 100644 --- a/services/nginx/app/routes/systemSearchRoute.php +++ b/services/nginx/app/routes/systemSearchRoute.php @@ -511,11 +511,7 @@ class systemSearchRoute } try { - $departments = $user->getGroup()->getDepartments(); - if (!is_array($departments)) { - return []; - } - return array_values(array_unique(array_map('intval', $departments))); + return $this->effectiveDepartmentIds($user); } catch (Throwable) { return []; } diff --git a/services/nginx/app/routes/vehiclePlateLastOrdersRoute.php b/services/nginx/app/routes/vehiclePlateLastOrdersRoute.php index c3391e20..99a74476 100644 --- a/services/nginx/app/routes/vehiclePlateLastOrdersRoute.php +++ b/services/nginx/app/routes/vehiclePlateLastOrdersRoute.php @@ -21,6 +21,7 @@ class vehiclePlateLastOrdersRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { + $departmentScope = $this->limitedBackofficeDepartmentScope($user); // Make sure the vehicle plate is set if (!(string)$this->fromRequest('plate')) { $response->error('Plate parameter is required', 400); @@ -29,7 +30,11 @@ class vehiclePlateLastOrdersRoute (new logs_o())->add('orders', 'global', 1, $user->id, 'FETCH_VEHICLE_ORDER_HISTORY', 'Successfully fetched vehicle order history'); // Return the list of departments $response->success( - (new orders_o())->getOrderHistoryByVehiclePlate($this->fromRequest('plate'), 5) + (new orders_o())->getOrderHistoryByVehiclePlate( + $this->fromRequest('plate'), + 5, + $departmentScope + ) ); } else { // Log the incident @@ -43,4 +48,4 @@ class vehiclePlateLastOrdersRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/vehiclePlateLookupRoute.php b/services/nginx/app/routes/vehiclePlateLookupRoute.php index 68d6670d..5e24fd25 100644 --- a/services/nginx/app/routes/vehiclePlateLookupRoute.php +++ b/services/nginx/app/routes/vehiclePlateLookupRoute.php @@ -23,13 +23,17 @@ class vehiclePlateLookupRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { + $departmentScope = $this->limitedBackofficeDepartmentScope($user); // Make sure the vehicle plate is set if (!(string)$this->fromRequest('plate')) { $response->error('Plate parameter is required', 400); } // Return the vehicle order history $response->success( - (new orders_o())->get_vehicle_order_history($this->fromRequest('plate')) + (new orders_o())->get_vehicle_order_history( + $this->fromRequest('plate'), + $departmentScope + ) ); } else { // Log the incident @@ -51,6 +55,7 @@ class vehiclePlateLookupRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { + $departmentScope = $this->limitedBackofficeDepartmentScope($user); // Make sure the vehicle plate is set self::requireParameters(['plate', 'customer_number', 'column']); self::requireType((string)self::getParameter('plate'), 'string'); @@ -65,6 +70,13 @@ class vehiclePlateLookupRoute if (!in_array((string)self::getParameter('column'), $allowedColumns)) { $response->error('Invalid column, allowed columns are: ' . implode(', ', $allowedColumns), 400); } + if ($departmentScope === []) { + $response->success([]); + } + $filters = ['customer_id' => self::getParameter('customer_number')]; + if ($departmentScope !== null) { + $filters['department_id'] = $departmentScope; + } // Return the vehicle order history $response->success( (new orders_o()) @@ -73,7 +85,7 @@ class vehiclePlateLookupRoute 1, 10, (string)self::getParameter('plate'), - ['customer_id' => self::getParameter('customer_number')], + $filters, ['created_at' => 'DESC'], function ($order) { return [ @@ -94,4 +106,4 @@ class vehiclePlateLookupRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/statistics/bookings_s.php b/services/nginx/app/statistics/bookings_s.php index 082505dc..9510155f 100644 --- a/services/nginx/app/statistics/bookings_s.php +++ b/services/nginx/app/statistics/bookings_s.php @@ -8,7 +8,10 @@ class bookings_s { use statistic_t; - public function get_new_bookings(): self + /** + * @param array|null $departmentIds Null keeps the existing unrestricted contract. + */ + public function get_new_bookings(?array $departmentIds = null): self { // Get the departments $departments_o = new \objects\departments_o(); @@ -16,9 +19,19 @@ class bookings_s $bookings_o = new \objects\bookings_o(); // Get the bookings - $bookings = $bookings_o->getFields(['id', 'department', 'date']); + $bookings = $departmentIds === null + ? $bookings_o->getFields(['id', 'department', 'date']) + : ($departmentIds === [] ? [] : $bookings_o->getFieldsWhere( + ['department' => $departmentIds], + ['id', 'department', 'date'] + )); // Get the departments - $departments = $departments_o->getFields(['id', 'name']); + $departments = $departmentIds === null + ? $departments_o->getFields(['id', 'name']) + : ($departmentIds === [] ? [] : $departments_o->getFieldsWhere( + ['id' => $departmentIds], + ['id', 'name'] + )); // For each year, add the months covered $this->add_monthly_labels(); // Add the counts for each department, for each month in each year @@ -40,4 +53,4 @@ class bookings_s } return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/statistics/orders_s.php b/services/nginx/app/statistics/orders_s.php index e9b6dae2..af3cf520 100644 --- a/services/nginx/app/statistics/orders_s.php +++ b/services/nginx/app/statistics/orders_s.php @@ -8,7 +8,10 @@ class orders_s { use statistic_t; - public function get_new_orders(): self + /** + * @param array|null $departmentIds Null keeps the existing unrestricted contract. + */ + public function get_new_orders(?array $departmentIds = null): self { // Get the departments $departments_o = new \objects\departments_o(); @@ -16,9 +19,19 @@ class orders_s $orders_o = new \objects\orders_o(); // Get the orders - $orders = $orders_o->getFields(['id', 'department_id', 'created_at']); + $orders = $departmentIds === null + ? $orders_o->getFields(['id', 'department_id', 'created_at']) + : ($departmentIds === [] ? [] : $orders_o->getFieldsWhere( + ['department_id' => $departmentIds], + ['id', 'department_id', 'created_at'] + )); // Get the departments - $departments = $departments_o->getFields(['id', 'name']); + $departments = $departmentIds === null + ? $departments_o->getFields(['id', 'name']) + : ($departmentIds === [] ? [] : $departments_o->getFieldsWhere( + ['id' => $departmentIds], + ['id', 'name'] + )); // For each year, add the months covered $this->add_monthly_labels(); // Add the counts for each department, for each month in each year @@ -40,4 +53,4 @@ class orders_s } return $this; } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Api/DepartmentCustomerPricingApiTest.php b/services/nginx/app/tests/Api/DepartmentCustomerPricingApiTest.php index e684a73e..7a4666ae 100644 --- a/services/nginx/app/tests/Api/DepartmentCustomerPricingApiTest.php +++ b/services/nginx/app/tests/Api/DepartmentCustomerPricingApiTest.php @@ -93,6 +93,16 @@ it('sets and applies department-specific customer discounts without legacy fallb 'department_id' => $fixture['department']['id'], 'user_id' => $fixture['customer']['id'], 'overrides' => [ + [ + 'is_category' => true, + 'product_or_category_id' => (string)$fixture['category']['id'], + 'discount' => 60, + ], + [ + 'is_category' => true, + 'product_or_category_id' => 'global', + 'discount' => 80, + ], [ 'is_category' => false, 'product_or_category_id' => $fixture['product']['id'], @@ -107,7 +117,12 @@ it('sets and applies department-specific customer discounts without legacy fallb ->assertEnvelope() ->assertSuccess(); - expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(25); + $productOverrides = array_values(array_filter( + $updated->data()['overrides'], + static fn(array $override): bool => $override['is_category'] === false + )); + expect($productOverrides)->toHaveCount(1); + expect($productOverrides[0]['percentage'] ?? null)->toBe(25); expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(750); $byCustomerNumber = api_client()->get( @@ -136,6 +151,124 @@ it('sets and applies department-specific customer discounts without legacy fallb expect((int)($productResponse->data()['price'] ?? 0))->toBe(750); }); +it('lets only the first writer replace customer pricing for a shared revision', function (): void { + api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'revision'); + api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'revision conflict'); + + $fixture = department_customer_pricing_setup(); + $session = api_fixtures()->createUserSession([ + limited_backoffice_service::PERMISSION_ACCESS, + limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING, + limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING, + 'department_access_' . (int)$fixture['department']['id'], + ]); + $path = '/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing'; + $query = '?user_id=' . (int)$fixture['customer']['id']; + + $initial = api_client()->get($path . $query, $session['headers']); + $initial->assertStatus(200)->assertEnvelope()->assertSuccess(); + $sharedRevision = $initial->data()['revision'] ?? null; + expect($sharedRevision)->toBeString()->toMatch('/^[a-f0-9]{64}$/'); + + $winner = api_client()->put($path, [ + 'user_id' => (int)$fixture['customer']['id'], + 'expected_revision' => $sharedRevision, + 'overrides' => [[ + 'is_category' => false, + 'product_or_category_id' => (int)$fixture['product']['id'], + 'discount' => 20, + ]], + ], $session['headers']); + $winner->assertStatus(200)->assertEnvelope()->assertSuccess(); + $winningRevision = $winner->data()['revision'] ?? null; + expect($winningRevision)->toBeString()->not->toBe($sharedRevision); + + $stale = api_client()->put($path, [ + 'user_id' => (int)$fixture['customer']['id'], + 'expected_revision' => $sharedRevision, + 'overrides' => [[ + 'is_category' => false, + 'product_or_category_id' => (int)$fixture['product']['id'], + 'discount' => 70, + ]], + ], $session['headers']); + $stale + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Pricing has changed. Reload and try again.'); + expect($stale->data()['code'] ?? null)->toBe('pricing_revision_conflict'); + expect($stale->data()['current_revision'] ?? null)->toBe($winningRevision); + + $reloaded = api_client()->get($path . $query, $session['headers']); + $reloaded->assertStatus(200)->assertEnvelope()->assertSuccess(); + expect($reloaded->data()['revision'] ?? null)->toBe($winningRevision); + expect($reloaded->data()['overrides'])->toHaveCount(1); + expect($reloaded->data()['overrides'][0]['percentage'] ?? null)->toBe(20); + expect($reloaded->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(800); +}); + +it('rejects ambiguous and duplicate customer price overrides without changing saved pricing', function (): void { + api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'validation'); + + $fixture = department_customer_pricing_setup(); + $session = api_fixtures()->createUserSession([ + limited_backoffice_service::PERMISSION_ACCESS, + limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING, + limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING, + 'department_access_' . (int)$fixture['department']['id'], + ]); + $path = '/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing'; + $initial = api_client()->get( + $path . '?user_id=' . (int)$fixture['customer']['id'], + $session['headers'] + ); + $revision = $initial->data()['revision']; + + foreach ([ + [[ + 'is_category' => false, + 'product_or_category_id' => (int)$fixture['product']['id'], + 'discount' => 10, + 'fixed_price' => 500, + ]], + [[ + 'is_category' => true, + 'product_or_category_id' => (string)$fixture['category']['id'], + 'discount' => 10, + 'fixed_price' => 500, + ]], + [ + [ + 'is_category' => false, + 'product_or_category_id' => (int)$fixture['product']['id'], + 'discount' => 10, + ], + [ + 'is_category' => false, + 'product_or_category_id' => (int)$fixture['product']['id'], + 'discount' => 20, + ], + ], + ] as $overrides) { + api_client()->put($path, [ + 'user_id' => (int)$fixture['customer']['id'], + 'expected_revision' => $revision, + 'overrides' => $overrides, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); + } + + $unchanged = api_client()->get( + $path . '?user_id=' . (int)$fixture['customer']['id'], + $session['headers'] + ); + expect($unchanged->data()['revision'] ?? null)->toBe($revision); + expect($unchanged->data()['overrides'])->toBe([]); +}); + it('limits department customer pricing to assigned limited-backoffice departments', function (): void { api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth'); api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth'); diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index d39bce5e..6e48467b 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -22,6 +22,46 @@ function limited_backoffice_manager_session(array $departmentIds, array $extraPe return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions)))); } +function limited_backoffice_managed_employee_session( + array $assignedDepartmentIds, + array $permissions, + array $extraRawDepartmentIds = [] +): array { + \classes\limited_backoffice_schema_bootstrap::ensureTables(); + + $rawDepartmentIds = array_values(array_unique(array_map( + 'intval', + array_merge($assignedDepartmentIds, $extraRawDepartmentIds) + ))); + foreach ($rawDepartmentIds as $departmentId) { + $permissions[] = 'department_access_' . $departmentId; + } + + $session = api_fixtures()->createUserSession( + array_values(array_unique($permissions)), + ['customer_number' => 0] + ); + $userId = (int)$session['user']['id']; + $groupId = (int)$session['user']['group_id']; + $departmentIdsJson = json_encode( + array_values(array_unique(array_map('intval', $assignedDepartmentIds))), + JSON_THROW_ON_ERROR + ); + $roleKey = 'cashier'; + $createdByUserId = 1; + $statement = api_test_runtime()->db()->prepare( + 'INSERT INTO `limited_backoffice_employees` + (`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`) + VALUES (?, ?, ?, ?, ?)' + ); + $statement->bind_param('iissi', $userId, $groupId, $roleKey, $departmentIdsJson, $createdByUserId); + $statement->execute(); + $statement->close(); + api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => $userId]); + + return $session; +} + function limited_backoffice_all_role_permissions(): array { return [ @@ -263,6 +303,62 @@ it('lists and updates explicit prices only for assigned departments', function ( expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(2222); }); +it('lets only the first writer replace department prices for a shared revision', function (): void { + api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'revision'); + api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'revision conflict'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Concurrent Prices']); + $category = api_fixtures()->createCategory(['name' => 'Limited Concurrent Price Category']); + $product = api_fixtures()->createProduct(['category' => $category['id']]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100); + $session = limited_backoffice_manager_session([(int)$department['id']]); + $path = '/limited-backoffice/departments/' . (int)$department['id'] . '/prices'; + + $initial = api_client()->get($path, $session['headers']); + $initial->assertStatus(200)->assertEnvelope()->assertSuccess(); + $sharedRevision = $initial->data()['revision'] ?? null; + expect($sharedRevision)->toBeString()->toMatch('/^[a-f0-9]{64}$/'); + + $winner = api_client()->put($path, [ + 'expected_revision' => $sharedRevision, + 'prices' => [ + ['product_id' => (int)$product['id'], 'price' => 200], + ], + ], $session['headers']); + $winner->assertStatus(200)->assertEnvelope()->assertSuccess(); + $winningRevision = $winner->data()['revision'] ?? null; + expect($winningRevision)->toBeString()->not->toBe($sharedRevision); + + $stale = api_client()->put($path, [ + 'expected_revision' => $sharedRevision, + 'prices' => [ + ['product_id' => (int)$product['id'], 'price' => 300], + ], + ], $session['headers']); + $stale + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Pricing has changed. Reload and try again.'); + expect($stale->data()['code'] ?? null)->toBe('pricing_revision_conflict'); + expect($stale->data()['current_revision'] ?? null)->toBe($winningRevision); + expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(200); + expect(limited_backoffice_price_rows((int)$department['id'], (int)$product['id']))->toHaveCount(1); + + api_client()->put($path, [ + 'expected_revision' => 'not-a-revision', + 'prices' => [ + ['product_id' => (int)$product['id'], 'price' => 400], + ], + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Expected revision is invalid.'); + expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(200); +}); + it('returns saved prices and collapses legacy duplicate department price rows', function (): void { api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'legacy duplicates'); @@ -1181,62 +1277,14 @@ it('caps manager-gated limited employee permissions while keeping baseline role ->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); }); -it('generates reusable QR login links for active scoped employees', function (): void { - api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'happy'); +it('retires reusable QR login links in favor of one-time grants', function (): void { + api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'retired'); - $department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Department']); - $session = limited_backoffice_manager_session([(int)$department['id']]); - - $created = api_client()->post('/limited-backoffice/employees', [ - 'display_name' => 'Limited QR Employee', - 'email' => 'limited-qr@example.test', - 'password' => 'Secret123!', - 'role_key' => 'viewer', - 'department_ids' => [(int)$department['id']], - ], $session['headers']); - $created - ->assertStatus(200) + api_client()->post('/limited-backoffice/employees/1/login-link') + ->assertStatus(410) ->assertEnvelope() - ->assertSuccess(); - - $employeeId = (int)($created->data()['id'] ?? 0); - expect($employeeId)->toBeGreaterThan(0); - limited_backoffice_cleanup_created_employee($employeeId); - - $response = api_client()->post( - '/limited-backoffice/employees/' . $employeeId . '/login-link', - [], - $session['headers'] - ); - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - $loginPath = (string)($response->data()['login_path'] ?? ''); - expect($response->data()['employee_id'] ?? null)->toBe($employeeId); - expect($loginPath)->toMatch('/^\/login\/qr\?token=[a-f0-9]{64}$/'); - - parse_str((string)parse_url($loginPath, PHP_URL_QUERY), $query); - $token = (string)($query['token'] ?? ''); - expect($token)->toMatch('/^[a-f0-9]{64}$/'); - - $tokenRow = api_test_runtime()->queryOne( - "SELECT `user_id`, `type` FROM `tokens` WHERE `token` = '" . - api_test_runtime()->db()->real_escape_string($token) . - "' LIMIT 1" - ); - expect($tokenRow)->not->toBeNull(); - expect((int)($tokenRow['user_id'] ?? 0))->toBe($employeeId); - expect($tokenRow['type'] ?? null)->toBe('AUTH_TOKEN'); - - $list = api_client()->get('/limited-backoffice/employees', $session['headers']); - $list - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - expect($list->body)->not->toContain($token); - expect($list->body)->not->toContain('login_path'); + ->assertSuccess(false) + ->assertMessage('Reusable employee login links are retired. Create a short-lived one-time login grant instead.'); }); it('creates, exchanges once, idempotently guards, and revokes scoped employee login grants', function (): void { @@ -1408,120 +1456,6 @@ it('creates, exchanges once, idempotently guards, and revokes scoped employee lo ->assertSuccess(false); }); -it('rejects invalid limited backoffice employee QR login link generation', function (): void { - api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth'); - api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation'); - - $department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Own']); - $otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Login Link Other']); - $session = limited_backoffice_manager_session([(int)$department['id']]); - $otherSession = limited_backoffice_manager_session([(int)$otherDepartment['id']]); - - $created = api_client()->post('/limited-backoffice/employees', [ - 'display_name' => 'Limited Link Target', - 'email' => 'limited-link-target@example.test', - 'password' => 'Secret123!', - 'role_key' => 'viewer', - 'department_ids' => [(int)$department['id']], - ], $session['headers']); - $employeeId = (int)($created->data()['id'] ?? 0); - expect($employeeId)->toBeGreaterThan(0); - limited_backoffice_cleanup_created_employee($employeeId); - - $withoutManageEmployees = api_fixtures()->createUserSession([ - limited_backoffice_service::PERMISSION_ACCESS, - 'department_access_' . (int)$department['id'], - ]); - api_client()->post( - '/limited-backoffice/employees/' . $employeeId . '/login-link', - [], - $withoutManageEmployees['headers'] - ) - ->assertStatus(403) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES]); - - api_client()->post( - '/limited-backoffice/employees/' . (int)$session['user']['id'] . '/login-link', - [], - $session['headers'] - ) - ->assertStatus(403) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Managers cannot edit themselves.'); - - api_client()->post('/limited-backoffice/employees/999999999/login-link', [], $session['headers']) - ->assertStatus(404) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Managed employee not found.'); - - $otherCreated = api_client()->post('/limited-backoffice/employees', [ - 'display_name' => 'Limited Other Department Target', - 'email' => 'limited-other-target@example.test', - 'password' => 'Secret123!', - 'role_key' => 'viewer', - 'department_ids' => [(int)$otherDepartment['id']], - ], $otherSession['headers']); - $otherEmployeeId = (int)($otherCreated->data()['id'] ?? 0); - expect($otherEmployeeId)->toBeGreaterThan(0); - limited_backoffice_cleanup_created_employee($otherEmployeeId); - - api_client()->post( - '/limited-backoffice/employees/' . $otherEmployeeId . '/login-link', - [], - $session['headers'] - ) - ->assertStatus(403) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]); - - api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']) - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(); - - api_client()->post('/limited-backoffice/employees/' . $employeeId . '/login-link', [], $session['headers']) - ->assertStatus(409) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Cannot create a login link for an inactive employee.'); - - $superuser = api_fixtures()->createUser(['group_id' => 1]); - api_test_runtime()->db()->query( - 'INSERT INTO `limited_backoffice_employees` - (`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`) - VALUES (' . (int)$superuser['id'] . ", 1, 'department_admin', '[" . (int)$department['id'] . "]', " . (int)$session['user']['id'] . ')' - ); - api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$superuser['id']]); - - api_client()->post('/limited-backoffice/employees/' . (int)$superuser['id'] . '/login-link', [], $session['headers']) - ->assertStatus(403) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Cannot manage superuser accounts.'); - - $sharedGroup = api_fixtures()->createGroup(); - $firstSharedUser = api_fixtures()->createUser(['group_id' => $sharedGroup['id']]); - api_fixtures()->createUser(['group_id' => $sharedGroup['id']]); - $departmentJson = '[' . (int)$department['id'] . ']'; - api_test_runtime()->db()->query( - 'INSERT INTO `limited_backoffice_employees` - (`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`) - VALUES (' . (int)$firstSharedUser['id'] . ', ' . (int)$sharedGroup['id'] . ", 'viewer', '" . $departmentJson . "', " . (int)$session['user']['id'] . ')' - ); - api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$firstSharedUser['id']]); - - api_client()->post('/limited-backoffice/employees/' . (int)$firstSharedUser['id'] . '/login-link', [], $session['headers']) - ->assertStatus(403) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('Cannot manage shared groups.'); -}); - it('includes limited employees in the regular employee list and protects raw user edits', function (): void { api_test_covers('GET /users', 'limited backoffice employee list'); api_test_covers('PUT /users', 'limited backoffice guard'); @@ -2389,3 +2323,166 @@ it('enforces department access when editing order items via PUT /order/items', f ->assertEnvelope() ->assertSuccess(); }); + +it('uses managed assignments as the authoritative scope even when raw department permissions drift', function (): void { + api_test_covers('GET /departments', 'managed scope'); + + $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Scope Allowed']); + $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Scope Denied']); + $session = limited_backoffice_managed_employee_session( + [(int)$allowedDepartment['id']], + ['list_departments'], + [(int)$deniedDepartment['id']] + ); + + $listed = api_client()->get('/departments', $session['headers']); + $listed->assertStatus(200)->assertEnvelope()->assertSuccess(); + $departmentIds = array_map('intval', array_column($listed->data(), 'id')); + expect($departmentIds) + ->toContain((int)$allowedDepartment['id']) + ->not->toContain((int)$deniedDepartment['id']); + + api_client()->get('/departments?id=' . (int)$deniedDepartment['id'], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false); +}); + +it('denies every attachment and legacy Stripe action outside the managed department assignment', function (): void { + api_test_covers('GET /orders/attachments', 'managed scope'); + api_test_covers('POST /orders/attachments/upload', 'managed scope'); + api_test_covers('DELETE /orders/attachments', 'managed scope'); + api_test_covers('GET /orders/attachments/download', 'managed scope'); + api_test_covers('DELETE /modules/stripe/invoice', 'managed scope'); + + $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Action Allowed']); + $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Action Denied']); + $customer = api_fixtures()->createUser(['display_name' => 'Managed Action Customer']); + $allowedOrder = api_fixtures()->createOrder([ + 'customer_id' => (int)$customer['customer_number'], + 'department_id' => (int)$allowedDepartment['id'], + ]); + $deniedOrder = api_fixtures()->createOrder([ + 'customer_id' => (int)$customer['customer_number'], + 'department_id' => (int)$deniedDepartment['id'], + ]); + $deniedAttachment = api_fixtures()->createOrderAttachment([ + 'order_id' => (int)$deniedOrder['id'], + ]); + $session = limited_backoffice_managed_employee_session( + [(int)$allowedDepartment['id']], + [ + 'list_order_attachments', + 'add_order_attachments', + 'delete_order_attachments', + 'download_order_attachments', + 'modules_stripe_invoice_send', + ], + [(int)$deniedDepartment['id']] + ); + + api_client()->get('/orders/attachments?id=' . (int)$allowedOrder['id'], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + api_client()->delete('/modules/stripe/invoice', ['order_id' => (int)$allowedOrder['id']], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + api_client()->get('/orders/attachments?id=' . (int)$deniedOrder['id'], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false); + api_client()->post('/orders/attachments/upload', [ + 'order_id' => (int)$deniedOrder['id'], + 'base64_file' => base64_encode('denied'), + 'file_name' => 'denied.txt', + ], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false); + api_client()->delete('/orders/attachments', [ + 'order_id' => (int)$deniedOrder['id'], + 'attachment_id' => (int)$deniedAttachment['id'], + ], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false); + api_client()->get( + '/orders/attachments/download?order_id=' . (int)$deniedOrder['id'] + . '&attachment_id=' . (int)$deniedAttachment['id'], + $session['headers'] + ) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false); + api_client()->delete('/modules/stripe/invoice', ['order_id' => (int)$deniedOrder['id']], $session['headers']) + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false); +}); + +it('filters dashboard statistics and vehicle history to managed department assignments', function (): void { + api_test_covers('GET /statistics/orders/new', 'managed scope'); + api_test_covers('GET /statistics/bookings/new', 'managed scope'); + api_test_covers('GET /department/license-plate/lookup', 'managed scope'); + api_test_covers('GET /department/vehicle/order/history', 'managed scope'); + + $allowedDepartmentName = 'Managed Dashboard Allowed'; + $deniedDepartmentName = 'Managed Dashboard Denied'; + $allowedDepartment = api_fixtures()->createDepartment(['name' => $allowedDepartmentName]); + $deniedDepartment = api_fixtures()->createDepartment(['name' => $deniedDepartmentName]); + $customer = api_fixtures()->createUser(['display_name' => 'Managed Dashboard Customer']); + $plate = 'SCOPE' . ((int)$customer['customer_number'] % 1000); + api_fixtures()->createOrder([ + 'customer_id' => (int)$customer['customer_number'], + 'department_id' => (int)$allowedDepartment['id'], + 'reg_1' => $plate, + ]); + api_fixtures()->createOrder([ + 'customer_id' => (int)$customer['customer_number'], + 'department_id' => (int)$deniedDepartment['id'], + 'reg_1' => $plate, + ]); + api_fixtures()->createLegacyBooking([ + 'customer_number' => (int)$customer['customer_number'], + 'department' => (int)$allowedDepartment['id'], + 'regNrTraekker' => $plate, + ]); + api_fixtures()->createLegacyBooking([ + 'customer_number' => (int)$customer['customer_number'], + 'department' => (int)$deniedDepartment['id'], + 'regNrTraekker' => $plate, + ]); + $session = limited_backoffice_managed_employee_session( + [(int)$allowedDepartment['id']], + [ + 'statistics_orders_new', + 'statistics_bookings_new', + 'department_license_plate_lookup', + 'department_vehicle_order_last_five', + ], + [(int)$deniedDepartment['id']] + ); + + foreach (['/statistics/orders/new', '/statistics/bookings/new'] as $path) { + $response = api_client()->get($path, $session['headers']); + $response->assertStatus(200)->assertEnvelope()->assertSuccess(); + $labels = array_column($response->data()['datasets'] ?? [], 'label'); + expect($labels) + ->toContain($allowedDepartmentName) + ->not->toContain($deniedDepartmentName); + } + + foreach (['/department/license-plate/lookup', '/department/vehicle/order/history'] as $path) { + $response = api_client()->get($path . '?plate=' . rawurlencode($plate), $session['headers']); + $response->assertStatus(200)->assertEnvelope()->assertSuccess(); + expect($response->data())->not->toBeEmpty(); + $departmentIds = array_values(array_unique(array_map( + static fn(array $order): int => (int)($order['department_id'] ?? 0), + $response->data() + ))); + expect($departmentIds)->toBe([(int)$allowedDepartment['id']]); + } +}); diff --git a/services/nginx/app/tests/Unit/Auth/PublicRegistrationAbuseControlsContractTest.php b/services/nginx/app/tests/Unit/Auth/PublicRegistrationAbuseControlsContractTest.php new file mode 100644 index 00000000..01b46cbf --- /dev/null +++ b/services/nginx/app/tests/Unit/Auth/PublicRegistrationAbuseControlsContractTest.php @@ -0,0 +1,18 @@ +toContain("\$this->post('/auth/register/cvr', function () {"); + expect($code)->toContain('$this->requireRecaptcha();'); + expect($code)->toContain("requirePublicRegistrationRateLimit('customer_ip', 'all')"); + expect($code)->toContain("'customer_identity', 'cvr:' . (string)\$cvr . ':phone:' . \$companyPhone, 3, 60 * 60, false"); + expect($code)->toContain("'enabled' => defined('redis')"); + expect($code)->toContain('incrementWithExpiration($key, $windowSeconds)'); + expect($code)->toContain("Registration protection is temporarily unavailable."); + expect($code)->not->toContain("\$attempts = (int)(\$redis->get(\$key)"); +}); diff --git a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php index c183ec3b..b0ee7522 100644 --- a/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php +++ b/services/nginx/app/tests/Unit/DailyReports/DepartmentDailyReportsComplaintsRouteContractTest.php @@ -13,7 +13,7 @@ it('wires complaint create, lookup, list, edit, and delete routes with validatio expect($content)->toContain("hasPermission('edit_department_daily_report_complaints')"); expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))"); expect($content)->toContain('requireDepartmentAccess((int)$complaint->department_id->value())'); - expect($content)->toContain("'department_id' => \$user->getGroup()->getDepartments()"); + expect($content)->toContain("'department_id' => \$this->effectiveDepartmentIds(\$user)"); expect($content)->toContain("getOrImportCustomerByCustomerNumber"); expect($content)->toContain("Search must be at least 2 characters"); expect($content)->toContain("Failed to fetch complaint customers from e-conomic"); diff --git a/services/nginx/app/tests/Unit/LimitedBackoffice/ManagedDepartmentScopeContractTest.php b/services/nginx/app/tests/Unit/LimitedBackoffice/ManagedDepartmentScopeContractTest.php new file mode 100644 index 00000000..f8540078 --- /dev/null +++ b/services/nginx/app/tests/Unit/LimitedBackoffice/ManagedDepartmentScopeContractTest.php @@ -0,0 +1,32 @@ +toContain('effectiveDepartmentIds('); + expect($code, $route)->not->toContain('getGroup()->getDepartments()'); + } + + $trait = (string)file_get_contents(app_path('traits/route_t.php')); + expect($trait)->toContain('public function effectiveDepartmentIds(object $user): array'); + expect($trait)->toContain('$managedScope = $this->limitedBackofficeDepartmentScope($user);'); + expect($trait)->toContain('return $departmentIds === [] ? [0] : $departmentIds;'); + + $orders = (string)file_get_contents(app_path('routes/ordersRoute.php')); + expect($orders)->toContain('$managedScope === null || in_array(10, $department_ids, true)'); + + $bookings = (string)file_get_contents(app_path('routes/bookingsRoute.php')); + expect($bookings)->toContain('$this->requireLimitedBackofficeDepartmentAccess('); + expect($bookings)->toContain('(int)$bookings_o->department->value()'); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/PublicSubuserRegistrationContractTest.php b/services/nginx/app/tests/Unit/Subusers/PublicSubuserRegistrationContractTest.php new file mode 100644 index 00000000..c840e346 --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/PublicSubuserRegistrationContractTest.php @@ -0,0 +1,69 @@ +toContain("\$this->post('/subusers', function () { \$this->registerPublicSubuser(); });"); + expect($normalized)->toContain("\$this->post('/subusers/me', function () { \$this->registerPublicSubuser(); });"); + expect(substr_count($normalized, '$this->registerPublicSubuser();'))->toBe(2); +}); + +it('requires abuse controls and serializes idempotent driver registration writes', function (): void { + $method = preg_replace('/\s+/', ' ', public_subuser_registration_method()); + + expect($method)->toContain('$this->requireRecaptcha();'); + expect($method)->toContain("'public_registration_ip'"); + expect($method)->toContain("'public_registration_identity'"); + expect($method)->toContain("3, 60 * 60, false"); + expect($method)->toContain('SELECT GET_LOCK(?, 5)'); + expect($method)->toContain('$db->conn->begin_transaction();'); + expect($method)->toContain('$db->conn->commit();'); + expect($method)->toContain('SELECT RELEASE_LOCK(?)'); + expect(strpos($method, '$db->conn->commit();'))->toBeLessThan(strpos($method, '$this->issueSetupInvite($subuser);')); + expect($method)->toContain('$this->storePublicRegistrationPending($setupToken, $customerNumber);'); + expect($method)->not->toContain('(new subuser_grants_o())->add('); +}); + +it('never returns setup credentials from either public driver registration alias', function (): void { + $method = public_subuser_registration_method(); + + expect($method)->not->toContain("'setup_link'"); + expect($method)->toContain("'message' => 'If the driver can be registered, setup instructions have been sent.'"); + expect($method)->not->toContain("'customer_number'"); + expect($method)->not->toContain("'already_registered'"); + + $openApi = (string)file_get_contents(app_path('openapi.yaml')); + $start = strpos($openApi, ' /subusers/me:'); + $end = strpos($openApi, ' /subusers/me/verification:', $start === false ? 0 : $start); + $operation = $start === false || $end === false ? '' : substr($openApi, $start, $end - $start); + expect($operation)->toContain('- g_recaptcha_response'); + expect($operation)->not->toContain('setup_token:'); + expect($operation)->not->toContain('setup_link:'); +}); + +it('creates and notifies a company grant only after the SMS setup token is completed', function (): void { + $code = preg_replace('/\s+/', ' ', (string)file_get_contents(app_path('routes/subusersRoute.php'))); + $setupStart = strpos($code, "\$this->post('/subusers/setup', function () {"); + $setupEnd = strpos($code, "\$this->post('/subusers/password-reset/request'", $setupStart === false ? 0 : $setupStart); + $setup = $setupStart === false || $setupEnd === false ? '' : substr($code, $setupStart, $setupEnd - $setupStart); + + expect($setup)->toContain('$this->getPublicRegistrationPending($token)'); + expect($setup)->toContain('(new subuser_grants_o())->add('); + expect($setup)->toContain('$this->notifyCustomerOfGrantRequest('); + expect($setup)->toContain('$this->clearPublicRegistrationPending($token)'); + expect(strpos($setup, '$subuser->update('))->toBeLessThan(strpos($setup, '(new subuser_grants_o())->add(')); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php index d2658c0d..6f08a49c 100644 --- a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php +++ b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php @@ -39,7 +39,8 @@ it('delivers purpose-bound SMS notifications for grant requests and decisions', expect($normalized)->toContain('PURPOSE_GRANT_DENY'); expect($normalized)->toContain('notifySubuserGrantDecision('); expect($normalized)->toContain("'decision_notification' => \$delivery"); - expect($normalized)->toContain("'customer_notification' => \$customerNotification"); + expect($normalized)->toContain('$this->getPublicRegistrationPending($token)'); + expect($normalized)->toContain('$this->notifyCustomerOfGrantRequest('); }); it('keeps forgot-password responses generic and protects reset tokens by purpose', function (): void { diff --git a/services/nginx/app/tests/auth/RegisterCvrTest.php b/services/nginx/app/tests/auth/RegisterCvrTest.php index 43ebdddc..17cf7058 100644 --- a/services/nginx/app/tests/auth/RegisterCvrTest.php +++ b/services/nginx/app/tests/auth/RegisterCvrTest.php @@ -8,6 +8,7 @@ namespace { $DEBUG = true; $_SERVER['REQUEST_URI'] = '/auth/register/cvr'; $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; } namespace classes { @@ -189,15 +190,20 @@ namespace classes { { public static array $sent = []; public static array $superuser_notifications = []; + public static array $failing_recipients = []; public static function reset(): void { self::$sent = []; self::$superuser_notifications = []; + self::$failing_recipients = []; } public function sendWelcomeEmailToCustomer($phone, $email): bool { + if (in_array((string)$email, self::$failing_recipients, true)) { + throw new \RuntimeException('Mock welcome delivery failure'); + } self::$sent[] = [ 'customer_number' => (int)$phone, 'email' => (string)$email, @@ -254,6 +260,7 @@ namespace objects { public static array $mock_importable_customer_numbers = []; public static bool $mock_external_lookup_enabled = true; public static array $interaction_log = []; + public static array $hydrated_contacts = []; public int $id = 0; private bool $exists = false; @@ -264,6 +271,7 @@ namespace objects { self::$mock_importable_customer_numbers = []; self::$mock_external_lookup_enabled = true; self::$interaction_log = []; + self::$hydrated_contacts = []; } public function getFieldsWhere(array $fieldsAndValues, array $fields): array @@ -318,6 +326,22 @@ namespace objects { { return $this->exists; } + + public function hydrateRegistrationContact( + string $email, + int $phoneNumber, + int $phoneCountryCode = 45, + ?string $contactName = null + ): void { + self::$hydrated_contacts[] = [ + 'customer_number' => $this->id, + 'email' => $email, + 'phone' => $phoneNumber, + 'phone_country_code' => $phoneCountryCode, + 'contact_name' => $contactName, + ]; + self::$interaction_log[] = 'hydrate:' . $this->id; + } } class logs_o @@ -403,6 +427,34 @@ namespace { } } + /** + * Each table-driven case is an independent registration request. Clear + * only the two production throttle keys that request can touch so the + * legacy harness does not leak rate-limit state between cases. + * + * @param array $params + */ + function reset_registration_throttles(array $params): void + { + if (!defined('redis')) { + return; + } + + $remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown')); + $keys = [ + 'auth_public_registration_throttle:customer_ip:' . hash('sha256', $remoteAddress . ':all'), + ]; + + if (isset($params['cvr'], $params['companyPhone'])) { + $identifier = 'cvr:' . (string)$params['cvr'] . ':phone:' . (int)$params['companyPhone']; + $keys[] = 'auth_public_registration_throttle:customer_identity:' . hash('sha256', $identifier); + } + + foreach ($keys as $key) { + constant('redis')->delete($key); + } + } + $router = new MockRouter(); $response = new \classes\response(); @@ -423,6 +475,8 @@ namespace { 'invoiceEmail' => 'test@test.com', 'contactEmail' => 'test@test.com', 'contactPhone' => 12345678, + 'contactPhoneCountryCode' => 45, + 'contactName' => 'Test Contact', 'g_recaptcha_response' => 'valid', ]; @@ -544,6 +598,13 @@ namespace { 'expected_status' => 200, 'assert' => static function (): void { assert_true(count(\classes\economic::$create_calls) === 0, 'Recovery must not create a second e-conomic customer.'); + assert_same_data(\objects\users_o::$hydrated_contacts, [[ + 'customer_number' => 12345678, + 'email' => 'test@test.com', + 'phone' => 12345678, + 'phone_country_code' => 45, + 'contact_name' => 'Test Contact', + ]], 'Recovery must hydrate the local registration contact.'); assert_true(count(\classes\email::$sent) === 2, 'Recovery must send two welcome emails.'); assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Local bootstrap must happen before sending emails.'); assert_true(\classes\email::$sent[0]['email'] === 'jm@truckwash.dk', 'Recovery should notify the internal welcome recipient first.'); @@ -614,6 +675,13 @@ namespace { assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.'); assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.'); assert_true(\classes\economic::$create_calls[0]['ean'] === '5790001234567', 'Fresh registration must pass normalized EAN to e-conomic.'); + assert_same_data(\objects\users_o::$hydrated_contacts, [[ + 'customer_number' => 12345678, + 'email' => 'test@test.com', + 'phone' => 87654320, + 'phone_country_code' => 45, + 'contact_name' => 'Test Contact', + ]], 'Fresh registration must hydrate the local registration contact.'); $companyInformation = \classes\economic::$create_calls[0]['company_information']; assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.'); assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.'); @@ -629,6 +697,29 @@ namespace { assert_true(\classes\slack::$customer_registration_notifications[0]['customer_number'] === 12345678, 'Fresh registration Slack notification must use the created customer number.'); }, ], + [ + 'name' => 'Welcome email failure does not roll back a completed registration', + 'params' => $baseParams, + 'setup' => static function (): void { + \classes\economic::$mock_create_response = (object)[ + 'customerNumber' => 12345678, + 'name' => 'Mock Company', + ]; + \classes\email::$failing_recipients = ['test@test.com']; + }, + 'expected_success' => (object)[ + 'customerNumber' => 12345678, + 'name' => 'Mock Company', + ], + 'expected_status' => 201, + 'assert' => static function (): void { + assert_true(count(\classes\email::$sent) === 1, 'Successful welcome deliveries should be preserved.'); + assert_true(count(\classes\email::$superuser_notifications) === 1, 'Superuser notification should continue after welcome email failure.'); + assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Slack notification should continue after welcome email failure.'); + $events = array_column(\objects\logs_o::$entries, 'event'); + assert_true(in_array('AUTH_REGISTER_CVR_WELCOME_EMAIL_FAILED', $events, true), 'Welcome email failure should be logged.'); + }, + ], [ 'name' => 'Successful registration falls back to the create response when immediate import lookup misses', 'params' => array_merge($baseParams, ['contactPhone' => 87654320]), @@ -749,6 +840,7 @@ namespace { \classes\virkdata::$mock_exception = null; \objects\users_o::reset(); \objects\logs_o::reset(); + reset_registration_throttles($test['params']); if (isset($test['setup'])) { $test['setup'](); diff --git a/services/nginx/app/traits/route_t.php b/services/nginx/app/traits/route_t.php index 8691a0c7..347bab4f 100644 --- a/services/nginx/app/traits/route_t.php +++ b/services/nginx/app/traits/route_t.php @@ -267,6 +267,18 @@ trait route_t */ public function requireDepartmentAccess(string $department, string|null $permission = null): void { + global $response; + + $departmentId = (int)$department; + $authenticatedUser = (new authentication())->get_user(); + if ($authenticatedUser instanceof \objects\users_o) { + $managedScope = (new \classes\limited_backoffice_service()) + ->managedEmployeeDepartmentIds($authenticatedUser); + if ($managedScope !== null && !in_array($departmentId, $managedScope, true)) { + $response->error('Department resource not found', 404); + } + } + self::requirePermission('department_access_' . $department . ($permission ? '_' . $permission : '')); } @@ -279,9 +291,80 @@ trait route_t */ public function hasDepartmentAccess(string $department, string|null $permission = null): bool { + $departmentId = (int)$department; + $authenticatedUser = (new authentication())->get_user(); + if ($authenticatedUser instanceof \objects\users_o) { + $managedScope = (new \classes\limited_backoffice_service()) + ->managedEmployeeDepartmentIds($authenticatedUser); + if ($managedScope !== null && !in_array($departmentId, $managedScope, true)) { + return false; + } + } + return self::hasPermission('department_access_' . $department . ($permission ? '_' . $permission : '')); } + /** + * Applies department scoping only to users managed by limited backoffice. + * Other user types retain the route's existing permission contract. + * + * @return array|null + */ + public function limitedBackofficeDepartmentScope(object $user): ?array + { + if (!$user instanceof \objects\users_o) { + return null; + } + + return (new \classes\limited_backoffice_service())->managedEmployeeDepartmentIds($user); + } + + /** + * Returns the authoritative managed assignment for limited-backoffice + * employees and preserves the legacy group-derived scope for every other + * account type. + * + * @return array + */ + public function effectiveDepartmentIds(object $user): array + { + $managedScope = $this->limitedBackofficeDepartmentScope($user); + if ($managedScope !== null) { + $departmentIds = array_values(array_unique(array_filter( + array_map('intval', $managedScope), + static fn (int $departmentId): bool => $departmentId > 0 + ))); + + // Empty filter arrays are treated as "no filter" by legacy query + // helpers. A corrupt or missing managed assignment must deny all, + // never widen a limited employee to global department access. + return $departmentIds === [] ? [0] : $departmentIds; + } + + if (!method_exists($user, 'getGroup')) { + return []; + } + + return array_values(array_unique(array_map( + 'intval', + (array)$user->getGroup()->getDepartments() + ))); + } + + public function requireLimitedBackofficeDepartmentAccess(object $user, int $departmentId): void + { + global $response; + + $departmentScope = $this->limitedBackofficeDepartmentScope($user); + if ($departmentScope === null) { + return; + } + + if (!in_array($departmentId, $departmentScope, true)) { + $response->error('Department resource not found', 404); + } + } + /** * Get parameters as an array from the request * @return array