Harden Sæby demo registration and department scope (#335)
Complete and secure public customer/driver registration, authoritative limited-backoffice department scope, one-time employee QR login, and pricing concurrency for the Sæby demo.
This commit is contained in:
+181
-13
@@ -1746,9 +1746,9 @@ paths:
|
|||||||
- Subusers
|
- Subusers
|
||||||
summary: Create a subuser registration
|
summary: Create a subuser registration
|
||||||
description: |
|
description: |
|
||||||
Creates a subuser (driver) account using a company's CVR and a phone number. Validates the
|
Starts a driver setup challenge using a company's CVR and a phone number. No company grant
|
||||||
CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled
|
is created until the driver proves possession of the phone by completing the SMS setup link.
|
||||||
sends a setup link by SMS for the user to complete registration.
|
The public response is uniform and never includes setup credentials or relationship state.
|
||||||
operationId: createSubuser
|
operationId: createSubuser
|
||||||
security: []
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -1761,6 +1761,7 @@ paths:
|
|||||||
- cvr
|
- cvr
|
||||||
- phone_country_code
|
- phone_country_code
|
||||||
- phone
|
- phone
|
||||||
|
- g_recaptcha_response
|
||||||
properties:
|
properties:
|
||||||
cvr:
|
cvr:
|
||||||
type: integer
|
type: integer
|
||||||
@@ -1774,24 +1775,23 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
description: Phone number (4–15 digits, no leading +)
|
description: Phone number (4–15 digits, no leading +)
|
||||||
example: 12345678
|
example: 12345678
|
||||||
|
g_recaptcha_response:
|
||||||
|
type: string
|
||||||
|
description: reCAPTCHA response token
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Subuser created (or pending setup) and company identified
|
description: Uniform driver registration acknowledgement
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
cvr:
|
message:
|
||||||
type: integer
|
type: string
|
||||||
example: 12345678
|
|
||||||
customer_number:
|
|
||||||
type: integer
|
|
||||||
description: Matched e-conomic customer number
|
|
||||||
example: 1000
|
|
||||||
'400': { $ref: '#/components/responses/BadRequest' }
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
'404': { $ref: '#/components/responses/NotFound' }
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
|
||||||
/subusers/{id}:
|
/subusers/{id}:
|
||||||
get:
|
get:
|
||||||
@@ -13543,8 +13543,72 @@ paths:
|
|||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$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':
|
'409':
|
||||||
$ref: '#/components/responses/Conflict'
|
$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:
|
/limited-backoffice/departments/{departmentId}/customer-pricing:
|
||||||
get:
|
get:
|
||||||
@@ -13593,7 +13657,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Limited Backoffice
|
- Limited Backoffice
|
||||||
summary: Replace limited-backoffice department customer pricing
|
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
|
operationId: setLimitedBackofficeDepartmentCustomerPricing
|
||||||
parameters:
|
parameters:
|
||||||
- name: departmentId
|
- name: departmentId
|
||||||
@@ -13622,7 +13686,7 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
'409':
|
'409':
|
||||||
$ref: '#/components/responses/Conflict'
|
$ref: '#/components/responses/PricingConflict'
|
||||||
|
|
||||||
/superuser/department/variables:
|
/superuser/department/variables:
|
||||||
get:
|
get:
|
||||||
@@ -14397,6 +14461,14 @@ components:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$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:
|
Unauthorized:
|
||||||
description: Unauthorized - Invalid or missing authentication token
|
description: Unauthorized - Invalid or missing authentication token
|
||||||
content:
|
content:
|
||||||
@@ -14590,6 +14662,8 @@ components:
|
|||||||
customer_number:
|
customer_number:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
expected_revision:
|
||||||
|
$ref: '#/components/schemas/PricingRevision'
|
||||||
overrides:
|
overrides:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -14606,6 +14680,8 @@ components:
|
|||||||
customer_number:
|
customer_number:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
expected_revision:
|
||||||
|
$ref: '#/components/schemas/PricingRevision'
|
||||||
overrides:
|
overrides:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -14636,6 +14712,7 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
minimum: 0
|
minimum: 0
|
||||||
|
description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both.
|
||||||
|
|
||||||
DepartmentCustomerPricingOverride:
|
DepartmentCustomerPricingOverride:
|
||||||
allOf:
|
allOf:
|
||||||
@@ -14722,6 +14799,8 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
display_name:
|
display_name:
|
||||||
type: string
|
type: string
|
||||||
|
revision:
|
||||||
|
$ref: '#/components/schemas/PricingRevision'
|
||||||
overrides:
|
overrides:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -14733,6 +14812,95 @@ components:
|
|||||||
meta:
|
meta:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
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:
|
includes:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class department_customer_pricing_service
|
|||||||
'customer' => $customer,
|
'customer' => $customer,
|
||||||
'overrides' => $overrides,
|
'overrides' => $overrides,
|
||||||
'categories' => $this->catalog($departmentId, $customer['id']),
|
'categories' => $this->catalog($departmentId, $customer['id']),
|
||||||
|
'revision' => $this->revision($overrides),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,34 +53,69 @@ class department_customer_pricing_service
|
|||||||
|
|
||||||
$normalized = $this->normalizeOverrides($departmentId, $overrides);
|
$normalized = $this->normalizeOverrides($departmentId, $overrides);
|
||||||
$overrideObject = new department_customer_price_overrides_o();
|
$overrideObject = new department_customer_price_overrides_o();
|
||||||
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
|
$expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null);
|
||||||
|
$existingOverrides = [];
|
||||||
$normalizedKeys = [];
|
$normalizedKeys = [];
|
||||||
foreach ($normalized as $override) {
|
foreach ($normalized as $override) {
|
||||||
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
|
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
global $db;
|
global $db;
|
||||||
$db->conn()->begin_transaction();
|
$mysqli = $db->conn();
|
||||||
|
$mysqli->begin_transaction();
|
||||||
try {
|
try {
|
||||||
$db->query(
|
$this->lockDepartment($departmentId);
|
||||||
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
|
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
|
||||||
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
|
$currentRevision = $this->revision($existingOverrides);
|
||||||
);
|
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
|
||||||
|
throw $this->revisionConflict($currentRevision);
|
||||||
foreach ($normalized as $override) {
|
|
||||||
$overrideObject->setPrice(
|
|
||||||
$departmentId,
|
|
||||||
$customer['id'],
|
|
||||||
(bool)$override['is_category'],
|
|
||||||
$override['product_or_category_id'],
|
|
||||||
(int)$override['percentage'],
|
|
||||||
$override['fixed_price']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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) {
|
} catch (\Throwable) {
|
||||||
$db->conn()->rollback();
|
$mysqli->rollback();
|
||||||
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
|
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +290,9 @@ class department_customer_pricing_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($isCategory) {
|
if ($isCategory) {
|
||||||
|
if ($fixedPrice !== null) {
|
||||||
|
throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400);
|
||||||
|
}
|
||||||
$fixedPrice = null;
|
$fixedPrice = null;
|
||||||
$objectId = (string)$objectId;
|
$objectId = (string)$objectId;
|
||||||
if ($objectId !== 'global') {
|
if ($objectId !== 'global') {
|
||||||
@@ -269,6 +308,14 @@ class department_customer_pricing_service
|
|||||||
}
|
}
|
||||||
|
|
||||||
$key = $this->overrideKey($isCategory, $objectId);
|
$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] = [
|
$normalized[$key] = [
|
||||||
'is_category' => $isCategory,
|
'is_category' => $isCategory,
|
||||||
'product_or_category_id' => $objectId,
|
'product_or_category_id' => $objectId,
|
||||||
@@ -313,6 +360,64 @@ class department_customer_pricing_service
|
|||||||
return ((int)$isCategory) . ':' . (string)$objectId;
|
return ((int)$isCategory) . ':' . (string)$objectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $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
|
private function assertDepartmentProduct(int $departmentId, int $productId): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|||||||
@@ -634,6 +634,27 @@ class limited_backoffice_service
|
|||||||
return $this->accessibleDepartmentIdsForGroup($groupId);
|
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<int, int>|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
|
* Loads department scope from an authoritative group identity rather than
|
||||||
* from user object properties that may be backed by a stale Redis value.
|
* from user object properties that may be backed by a stale Redis value.
|
||||||
@@ -753,6 +774,7 @@ class limited_backoffice_service
|
|||||||
return [
|
return [
|
||||||
'department' => $department,
|
'department' => $department,
|
||||||
'categories' => $catalog['categories'],
|
'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 = $this->mysqli();
|
||||||
$mysqli->begin_transaction();
|
$mysqli->begin_transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$this->lockDepartmentForPricingUpdate($departmentId);
|
||||||
|
$currentRevision = $this->departmentPricesRevision($departmentId);
|
||||||
|
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
|
||||||
|
throw $this->pricingRevisionConflict($currentRevision);
|
||||||
|
}
|
||||||
|
|
||||||
$deleteStatement = $mysqli->prepare(
|
$deleteStatement = $mysqli->prepare(
|
||||||
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
|
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
|
||||||
);
|
);
|
||||||
@@ -820,15 +849,22 @@ class limited_backoffice_service
|
|||||||
|
|
||||||
foreach ($normalizedPrices as $productId => $price) {
|
foreach ($normalizedPrices as $productId => $price) {
|
||||||
$deleteStatement->bind_param('ii', $departmentId, $productId);
|
$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->bind_param('iii', $departmentId, $productId, $price);
|
||||||
$insertStatement->execute();
|
if (!$insertStatement->execute()) {
|
||||||
|
throw new \RuntimeException('Unable to save department price.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$deleteStatement->close();
|
$deleteStatement->close();
|
||||||
$insertStatement->close();
|
$insertStatement->close();
|
||||||
$mysqli->commit();
|
$mysqli->commit();
|
||||||
|
} catch (limited_backoffice_exception $exception) {
|
||||||
|
$mysqli->rollback();
|
||||||
|
throw $exception;
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
$mysqli->rollback();
|
$mysqli->rollback();
|
||||||
throw new limited_backoffice_exception('Unable to update department prices.', 500);
|
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}
|
* Applies the target and department boundary used by one-time login grants.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
public function assertEmployeeLoginTarget(
|
public function assertEmployeeLoginTarget(
|
||||||
users_o $manager,
|
users_o $manager,
|
||||||
@@ -1406,6 +1414,80 @@ class limited_backoffice_service
|
|||||||
return $db->conn();
|
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
|
private function tableHasColumn(string $table, string $column): bool
|
||||||
{
|
{
|
||||||
$cacheKey = $table . '.' . $column;
|
$cacheKey = $table . '.' . $column;
|
||||||
|
|||||||
@@ -636,6 +636,24 @@ class redis implements redis_i
|
|||||||
return $this;
|
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
|
public function generateTemporaryCacheKey(): string
|
||||||
{
|
{
|
||||||
// Generate a temporary cache key
|
// Generate a temporary cache key
|
||||||
|
|||||||
@@ -271,12 +271,22 @@ class orders_o extends db
|
|||||||
* @param int $entries The number of last entries to return (default 10)
|
* @param int $entries The number of last entries to return (default 10)
|
||||||
* @return array The orders for the vehicle plate
|
* @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;
|
global $db;
|
||||||
$plate = $db->escape_string($plate);
|
$plate = $db->escape_string($plate);
|
||||||
$entries = max(1, $entries);
|
$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);
|
$result = $db->query($sql);
|
||||||
return $db->fetch_all($result);
|
return $db->fetch_all($result);
|
||||||
}
|
}
|
||||||
@@ -622,10 +632,18 @@ class orders_o extends db
|
|||||||
* @param string $plate The vehicle plate
|
* @param string $plate The vehicle plate
|
||||||
* @return array The order history
|
* @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;
|
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) {
|
if (!$stmt) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -636,6 +654,26 @@ class orders_o extends db
|
|||||||
return $db->fetch_all($result);
|
return $db->fetch_all($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int>|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
|
* @throws Exception If the order is not selected
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1054,26 +1054,24 @@ class users_o extends db
|
|||||||
|
|
||||||
$overrides = new department_customer_price_overrides_o();
|
$overrides = new department_customer_price_overrides_o();
|
||||||
$product = (new products_o())->select((int)$product_id);
|
$product = (new products_o())->select((int)$product_id);
|
||||||
$discounts = [];
|
|
||||||
|
|
||||||
$productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id);
|
$productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id);
|
||||||
if ($productRow !== null) {
|
if ($productRow !== null) {
|
||||||
$discounts[] = (int)$productRow['percentage'];
|
return (int)$productRow['percentage'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((bool)$product->apply_category_discount->value()) {
|
if ((bool)$product->apply_category_discount->value()) {
|
||||||
$categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value());
|
$categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value());
|
||||||
if ($categoryRow !== null) {
|
if ($categoryRow !== null) {
|
||||||
$discounts[] = (int)$categoryRow['percentage'];
|
return (int)$categoryRow['percentage'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global');
|
$globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global');
|
||||||
if ($globalRow !== null) {
|
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();
|
$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
|
public function setSMSNotificationsEnabled(bool $enabled): void
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
|||||||
+192
-14
@@ -1998,9 +1998,9 @@ paths:
|
|||||||
- Subusers
|
- Subusers
|
||||||
summary: Create a subuser registration
|
summary: Create a subuser registration
|
||||||
description: |
|
description: |
|
||||||
Creates a subuser (driver) account using a company's CVR and a phone number. Validates the
|
Starts a driver setup challenge using a company's CVR and a phone number. No company grant
|
||||||
CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled
|
is created until the driver proves possession of the phone by completing the SMS setup link.
|
||||||
sends a setup link by SMS for the user to complete registration.
|
The public response is uniform and never includes setup credentials or relationship state.
|
||||||
operationId: createSubuser
|
operationId: createSubuser
|
||||||
security: []
|
security: []
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -2013,6 +2013,7 @@ paths:
|
|||||||
- cvr
|
- cvr
|
||||||
- phone_country_code
|
- phone_country_code
|
||||||
- phone
|
- phone
|
||||||
|
- g_recaptcha_response
|
||||||
properties:
|
properties:
|
||||||
cvr:
|
cvr:
|
||||||
type: integer
|
type: integer
|
||||||
@@ -2026,24 +2027,27 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
description: Phone number (4–15 digits, no leading +)
|
description: Phone number (4–15 digits, no leading +)
|
||||||
example: 12345678
|
example: 12345678
|
||||||
|
g_recaptcha_response:
|
||||||
|
type: string
|
||||||
|
description: reCAPTCHA response token
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Subuser created (or pending setup) and company identified
|
description: Uniform driver registration acknowledgement
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
cvr:
|
message:
|
||||||
type: integer
|
type: string
|
||||||
example: 12345678
|
|
||||||
customer_number:
|
|
||||||
type: integer
|
|
||||||
description: Matched e-conomic customer number
|
|
||||||
example: 1000
|
|
||||||
'400': { $ref: '#/components/responses/BadRequest' }
|
'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' }
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
'503': { $ref: '#/components/responses/ServiceUnavailable' }
|
||||||
|
|
||||||
/subusers/me/verification:
|
/subusers/me/verification:
|
||||||
get:
|
get:
|
||||||
@@ -3422,7 +3426,6 @@ paths:
|
|||||||
- invoiceEmail
|
- invoiceEmail
|
||||||
- contactEmail
|
- contactEmail
|
||||||
- contactPhone
|
- contactPhone
|
||||||
- contactName
|
|
||||||
- g_recaptcha_response
|
- g_recaptcha_response
|
||||||
properties:
|
properties:
|
||||||
cvr:
|
cvr:
|
||||||
@@ -3457,6 +3460,13 @@ paths:
|
|||||||
minimum: 10000000
|
minimum: 10000000
|
||||||
maximum: 9999999999
|
maximum: 9999999999
|
||||||
example: 21754690
|
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:
|
contactName:
|
||||||
type: string
|
type: string
|
||||||
description: Contact person name
|
description: Contact person name
|
||||||
@@ -14438,8 +14448,72 @@ paths:
|
|||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$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':
|
'409':
|
||||||
$ref: '#/components/responses/Conflict'
|
$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:
|
/limited-backoffice/departments/{departmentId}/customer-pricing:
|
||||||
get:
|
get:
|
||||||
@@ -14488,7 +14562,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Limited Backoffice
|
- Limited Backoffice
|
||||||
summary: Replace limited-backoffice department customer pricing
|
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
|
operationId: setLimitedBackofficeDepartmentCustomerPricing
|
||||||
parameters:
|
parameters:
|
||||||
- name: departmentId
|
- name: departmentId
|
||||||
@@ -14517,7 +14591,7 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
'409':
|
'409':
|
||||||
$ref: '#/components/responses/Conflict'
|
$ref: '#/components/responses/PricingConflict'
|
||||||
|
|
||||||
/limited-backoffice/employees/{employeeId}/login-grants:
|
/limited-backoffice/employees/{employeeId}/login-grants:
|
||||||
post:
|
post:
|
||||||
@@ -15400,6 +15474,14 @@ components:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$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:
|
Unauthorized:
|
||||||
description: Unauthorized - Invalid or missing authentication token
|
description: Unauthorized - Invalid or missing authentication token
|
||||||
content:
|
content:
|
||||||
@@ -15926,6 +16008,8 @@ components:
|
|||||||
customer_number:
|
customer_number:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
expected_revision:
|
||||||
|
$ref: '#/components/schemas/PricingRevision'
|
||||||
overrides:
|
overrides:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -15942,6 +16026,8 @@ components:
|
|||||||
customer_number:
|
customer_number:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
expected_revision:
|
||||||
|
$ref: '#/components/schemas/PricingRevision'
|
||||||
overrides:
|
overrides:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -15972,6 +16058,7 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
nullable: true
|
nullable: true
|
||||||
minimum: 0
|
minimum: 0
|
||||||
|
description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both.
|
||||||
|
|
||||||
DepartmentCustomerPricingOverride:
|
DepartmentCustomerPricingOverride:
|
||||||
allOf:
|
allOf:
|
||||||
@@ -16058,6 +16145,8 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
display_name:
|
display_name:
|
||||||
type: string
|
type: string
|
||||||
|
revision:
|
||||||
|
$ref: '#/components/schemas/PricingRevision'
|
||||||
overrides:
|
overrides:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -16069,6 +16158,95 @@ components:
|
|||||||
meta:
|
meta:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
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:
|
includes:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class authRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
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
|
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
|
||||||
{
|
{
|
||||||
return 'passkey_challenge_principal:' . $challengeToken;
|
return 'passkey_challenge_principal:' . $challengeToken;
|
||||||
@@ -64,6 +67,38 @@ class authRoute
|
|||||||
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
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
|
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
|
||||||
{
|
{
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
@@ -403,10 +438,10 @@ class authRoute
|
|||||||
$recaptcha = (new recaptcha())->getPublicConfig();
|
$recaptcha = (new recaptcha())->getPublicConfig();
|
||||||
$response->success([
|
$response->success([
|
||||||
'rate_limit' => [
|
'rate_limit' => [
|
||||||
'enabled' => false,
|
'enabled' => defined('redis'),
|
||||||
'limit' => 0,
|
'limit' => self::PUBLIC_REGISTRATION_RATE_LIMIT,
|
||||||
'remaining' => 0,
|
'remaining' => self::PUBLIC_REGISTRATION_RATE_LIMIT,
|
||||||
'reset' => 0,
|
'reset' => self::PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS,
|
||||||
'warning' => null
|
'warning' => null
|
||||||
],
|
],
|
||||||
'recaptcha' => $recaptcha
|
'recaptcha' => $recaptcha
|
||||||
@@ -461,6 +496,9 @@ class authRoute
|
|||||||
$contactEmail = self::getParameter('contactEmail');
|
$contactEmail = self::getParameter('contactEmail');
|
||||||
$contactPhone = (int)self::getParameter('contactPhone');
|
$contactPhone = (int)self::getParameter('contactPhone');
|
||||||
$contactName = self::getParameter('contactName');
|
$contactName = self::getParameter('contactName');
|
||||||
|
$contactPhoneCountryCode = self::isParametersSet(['contactPhoneCountryCode'])
|
||||||
|
? (int)self::getParameter('contactPhoneCountryCode')
|
||||||
|
: 45;
|
||||||
$ean = null;
|
$ean = null;
|
||||||
/**
|
/**
|
||||||
* Validate
|
* Validate
|
||||||
@@ -475,11 +513,17 @@ class authRoute
|
|||||||
self::requireType($invoiceEmail, $this->type_string());
|
self::requireType($invoiceEmail, $this->type_string());
|
||||||
self::requireMinLength('invoiceEmail', 5);
|
self::requireMinLength('invoiceEmail', 5);
|
||||||
self::requireMaxLength('invoiceEmail', 255);
|
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)) {
|
if (self::isParametersSet(['contactEmail']) && !is_null($contactEmail)) {
|
||||||
self::requireType($contactEmail, $this->type_string());
|
self::requireType($contactEmail, $this->type_string());
|
||||||
self::requireMinLength('contactEmail', 5);
|
self::requireMinLength('contactEmail', 5);
|
||||||
self::requireMaxLength('contactEmail', 255);
|
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
|
* If the contact phone is set, validate it
|
||||||
@@ -489,6 +533,17 @@ class authRoute
|
|||||||
self::requireMinValue($contactPhone, 10000000);
|
self::requireMinValue($contactPhone, 10000000);
|
||||||
self::requireMaxValue($contactPhone, 9999999999);
|
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'])) {
|
if (self::isParametersSet(['ean'])) {
|
||||||
try {
|
try {
|
||||||
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
|
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
|
||||||
@@ -527,7 +582,15 @@ class authRoute
|
|||||||
$response->error('Company phone number already registered', 400);
|
$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);
|
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
||||||
$response->success($matchingEconomicCustomer, 200);
|
$response->success($matchingEconomicCustomer, 200);
|
||||||
}
|
}
|
||||||
@@ -592,7 +655,11 @@ class authRoute
|
|||||||
$economic,
|
$economic,
|
||||||
(string)$cvr,
|
(string)$cvr,
|
||||||
$companyPhone,
|
$companyPhone,
|
||||||
(string)$invoiceEmail
|
(string)$invoiceEmail,
|
||||||
|
(string)$contactEmail,
|
||||||
|
(int)$contactPhone,
|
||||||
|
$contactPhoneCountryCode,
|
||||||
|
is_scalar($contactName) ? (string)$contactName : null
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($recoveredCustomer !== 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);
|
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
||||||
$response->success($result, 201);
|
$response->success($result, 201);
|
||||||
});
|
});
|
||||||
@@ -964,7 +1039,11 @@ class authRoute
|
|||||||
economic $economic,
|
economic $economic,
|
||||||
string $cvr,
|
string $cvr,
|
||||||
int $customerNumber,
|
int $customerNumber,
|
||||||
string $invoiceEmail
|
string $invoiceEmail,
|
||||||
|
string $contactEmail,
|
||||||
|
int $contactPhone,
|
||||||
|
int $contactPhoneCountryCode,
|
||||||
|
?string $contactName
|
||||||
): ?object {
|
): ?object {
|
||||||
// The upstream POST can commit before the client receives a validation/transport error.
|
// 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.
|
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
|
||||||
@@ -985,7 +1064,15 @@ class authRoute
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
|
$customer = $this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
|
||||||
|
$this->hydrateRegistrationContact(
|
||||||
|
$customer,
|
||||||
|
$customerNumber,
|
||||||
|
$contactEmail,
|
||||||
|
$contactPhone,
|
||||||
|
$contactPhoneCountryCode,
|
||||||
|
$contactName
|
||||||
|
);
|
||||||
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
|
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
|
||||||
|
|
||||||
return $matchingEconomicCustomer;
|
return $matchingEconomicCustomer;
|
||||||
@@ -1059,6 +1146,30 @@ class authRoute
|
|||||||
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
$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
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -1068,12 +1179,21 @@ class authRoute
|
|||||||
$jimmyEmail = 'jm@truckwash.dk';
|
$jimmyEmail = 'jm@truckwash.dk';
|
||||||
//$infoEmail = "info@truckwash.dk";
|
//$infoEmail = "info@truckwash.dk";
|
||||||
//$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian.
|
//$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian.
|
||||||
$email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail);
|
foreach ([$jimmyEmail, $invoiceEmail] as $recipient) {
|
||||||
$email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail);
|
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 {
|
try {
|
||||||
$email->sendNewCustomerRegistrationNotifications($customerNumber);
|
$email->sendNewCustomerRegistrationNotifications($customerNumber);
|
||||||
} catch (Exception $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [
|
||||||
'customerNumber' => $customerNumber,
|
'customerNumber' => $customerNumber,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
@@ -1082,7 +1202,7 @@ class authRoute
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
(new slack())->send_customer_registration_notification($customerNumber);
|
(new slack())->send_customer_registration_notification($customerNumber);
|
||||||
} catch (Exception $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [
|
||||||
'customerNumber' => $customerNumber,
|
'customerNumber' => $customerNumber,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class bookingsRoute
|
|||||||
if (!$user->hasAccessToBooking((int)self::getParameter('id'))) {
|
if (!$user->hasAccessToBooking((int)self::getParameter('id'))) {
|
||||||
$response->forbidden(['list_bookings']);
|
$response->forbidden(['list_bookings']);
|
||||||
}
|
}
|
||||||
|
$this->requireLimitedBackofficeDepartmentAccess(
|
||||||
|
$user,
|
||||||
|
(int)$bookings_o->department->value()
|
||||||
|
);
|
||||||
// Return the booking
|
// Return the booking
|
||||||
$response->success(
|
$response->success(
|
||||||
$bookings_o->asArray()
|
$bookings_o->asArray()
|
||||||
@@ -68,7 +72,7 @@ class bookingsRoute
|
|||||||
},
|
},
|
||||||
$bookings_o->forceRestrictFilters(
|
$bookings_o->forceRestrictFilters(
|
||||||
[
|
[
|
||||||
'department' => $user->getGroup()->getDepartments(),
|
'department' => $this->effectiveDepartmentIds($user),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class departmentDailyReportsRoute
|
|||||||
$department_daily_reports_o->forceRestrictFilters(
|
$department_daily_reports_o->forceRestrictFilters(
|
||||||
[
|
[
|
||||||
// This makes sure that the user can only see orders from the departments they explicitly have access to
|
// 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(
|
->listObjectsWithPaginationIfSet(
|
||||||
fn (array $complaint): array => $repository->parseComplaint($complaint),
|
fn (array $complaint): array => $repository->parseComplaint($complaint),
|
||||||
$repository->forceRestrictFilters([
|
$repository->forceRestrictFilters([
|
||||||
'department_id' => $user->getGroup()->getDepartments(),
|
'department_id' => $this->effectiveDepartmentIds($user),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class departmentTimeBookingsRoute
|
|||||||
$department_time_bookings_opening_hours->forceRestrictFilters(
|
$department_time_bookings_opening_hours->forceRestrictFilters(
|
||||||
[
|
[
|
||||||
// This makes sure that the user can only see orders from the departments they explicitly have access to
|
// 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(
|
$department_time_bookings_types->forceRestrictFilters(
|
||||||
[
|
[
|
||||||
// This makes sure that the user can only see orders from the departments they explicitly have access to
|
// 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(
|
$department_time_bookings_entries->forceRestrictFilters(
|
||||||
[
|
[
|
||||||
// This makes sure that the user can only see orders from the departments they explicitly have access to
|
// 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)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ class departmentsRoute
|
|||||||
{
|
{
|
||||||
use route_t;
|
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;
|
global $response;
|
||||||
|
|
||||||
@@ -43,6 +47,9 @@ class departmentsRoute
|
|||||||
unset($filters['visible'], $filters['archived']);
|
unset($filters['visible'], $filters['archived']);
|
||||||
$filters['visible'] = 1;
|
$filters['visible'] = 1;
|
||||||
$filters['archived'] = $archived;
|
$filters['archived'] = $archived;
|
||||||
|
if ($departmentScope !== null) {
|
||||||
|
$filters['id'] = array_values(array_map('intval', $departmentScope));
|
||||||
|
}
|
||||||
|
|
||||||
return $filters;
|
return $filters;
|
||||||
}
|
}
|
||||||
@@ -83,17 +90,23 @@ class departmentsRoute
|
|||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENTS', 'Successfully listed departments');
|
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENTS', 'Successfully listed departments');
|
||||||
// Check if the id is set in the request
|
// Check if the id is set in the request
|
||||||
if (self::isParametersSet(['id'])) {
|
if (self::isParametersSet(['id'])) {
|
||||||
|
$departmentId = (int)self::getParameter('id');
|
||||||
|
$this->requireLimitedBackofficeDepartmentAccess($user, $departmentId);
|
||||||
// Return the department
|
// Return the department
|
||||||
$response->success(
|
$response->success(
|
||||||
(new departments_o())->select((int)self::getParameter('id'))->asArray([
|
(new departments_o())->select($departmentId)->asArray([
|
||||||
'slack_webhook' => $user->hasPermission('view_slack_webhook')
|
'slack_webhook' => $user->hasPermission('view_slack_webhook')
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if ($departmentScope === []) {
|
||||||
|
$response->success([]);
|
||||||
|
}
|
||||||
$departments_o = new departments_o();
|
$departments_o = new departments_o();
|
||||||
// Return the list of departments
|
// Return the list of departments
|
||||||
$response->success(
|
$response->success(
|
||||||
@@ -141,7 +154,8 @@ class departmentsRoute
|
|||||||
},
|
},
|
||||||
$this->buildDepartmentListFilters(
|
$this->buildDepartmentListFilters(
|
||||||
$departments_o,
|
$departments_o,
|
||||||
$user->hasPermission('superuser_fetch_department')
|
$user->hasPermission('superuser_fetch_department'),
|
||||||
|
$departmentScope
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -118,15 +118,12 @@ class limitedBackofficeRoute
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
|
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
|
||||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
global $response;
|
||||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
$response->error(
|
||||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
'Reusable employee login links are retired. Create a short-lived one-time login grant instead.',
|
||||||
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
|
410
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, [
|
|
||||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
|
||||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->post('/limited-backoffice/employees/{employeeId}/login-grants', function () {
|
$this->post('/limited-backoffice/employees/{employeeId}/login-grants', function () {
|
||||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ class moduleStripeRoute
|
|||||||
|
|
||||||
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
|
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
|
||||||
$order->requireSelected();
|
$order->requireSelected();
|
||||||
|
$this->requireLimitedBackofficeDepartmentAccess(
|
||||||
|
$user,
|
||||||
|
(int)$order->department_id->value()
|
||||||
|
);
|
||||||
|
|
||||||
if (!$order->stripe_module_orders->exists()) {
|
if (!$order->stripe_module_orders->exists()) {
|
||||||
$response->success([
|
$response->success([
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class orderBookingRoute
|
|||||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||||
$forcedFilters = $object->forceRestrictFilters([
|
$forcedFilters = $object->forceRestrictFilters([
|
||||||
...($has_permission_other && $user !== false ? [
|
...($has_permission_other && $user !== false ? [
|
||||||
'department' => $user->getGroup()->getDepartments()
|
'department' => $this->effectiveDepartmentIds($user)
|
||||||
] : []),
|
] : []),
|
||||||
...(!$has_permission_other && $effectiveCustomer !== null ? [
|
...(!$has_permission_other && $effectiveCustomer !== null ? [
|
||||||
'customer_number' => [(int)$effectiveCustomer]
|
'customer_number' => [(int)$effectiveCustomer]
|
||||||
@@ -214,7 +214,7 @@ class orderBookingRoute
|
|||||||
if ($requestedDepartmentId !== null) {
|
if ($requestedDepartmentId !== null) {
|
||||||
$departmentIds = [$requestedDepartmentId];
|
$departmentIds = [$requestedDepartmentId];
|
||||||
} elseif ($hasPermissionOther && $user !== false) {
|
} elseif ($hasPermissionOther && $user !== false) {
|
||||||
$departmentIds = array_map('intval', $user->getGroup()->getDepartments());
|
$departmentIds = $this->effectiveDepartmentIds($user);
|
||||||
if ($departmentIds === []) {
|
if ($departmentIds === []) {
|
||||||
$response->success([
|
$response->success([
|
||||||
'past' => 0,
|
'past' => 0,
|
||||||
|
|||||||
@@ -111,11 +111,14 @@ class ordersRoute
|
|||||||
// Build department filter when listing as department/admin
|
// Build department filter when listing as department/admin
|
||||||
$department_ids = [];
|
$department_ids = [];
|
||||||
if ($has_permission_other) {
|
if ($has_permission_other) {
|
||||||
$department_ids = $user->getGroup()->getDepartments();
|
$department_ids = $this->effectiveDepartmentIds($user);
|
||||||
}
|
}
|
||||||
if (self::isParametersSet(['show_wash_subscription'])) {
|
if (self::isParametersSet(['show_wash_subscription'])) {
|
||||||
if (self::getParameter('show_wash_subscription') === 'true') {
|
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()) {
|
if (!$order->exists()) {
|
||||||
$response->error('Order not found', 400);
|
$response->error('Order not found', 400);
|
||||||
}
|
}
|
||||||
|
$this->requireLimitedBackofficeDepartmentAccess(
|
||||||
|
$user,
|
||||||
|
(int)$order->department_id->value()
|
||||||
|
);
|
||||||
// Permissions (subuser-aware)
|
// Permissions (subuser-aware)
|
||||||
$permission_own = self::definePermission('list_own_order_attachments', subusers_permission_node_key::ORDERS_LIST);
|
$permission_own = self::definePermission('list_own_order_attachments', subusers_permission_node_key::ORDERS_LIST);
|
||||||
$permission_other = self::definePermission('list_order_attachments');
|
$permission_other = self::definePermission('list_order_attachments');
|
||||||
@@ -2019,6 +2026,10 @@ class ordersRoute
|
|||||||
if (!$order->exists()) {
|
if (!$order->exists()) {
|
||||||
$response->error('Order not found', 404);
|
$response->error('Order not found', 404);
|
||||||
}
|
}
|
||||||
|
$this->requireLimitedBackofficeDepartmentAccess(
|
||||||
|
$user,
|
||||||
|
(int)$order->department_id->value()
|
||||||
|
);
|
||||||
if (!$hasPermissionOther) {
|
if (!$hasPermissionOther) {
|
||||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||||
if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) {
|
if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class plateScansRoute
|
|||||||
},
|
},
|
||||||
$number_plate_scans->forceRestrictFilters(
|
$number_plate_scans->forceRestrictFilters(
|
||||||
[
|
[
|
||||||
'department_id' => $user->getGroup()->getDepartments()
|
'department_id' => $this->effectiveDepartmentIds($user)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class potentialOrderMatchesRoute
|
|||||||
$potentialOrderMatches->forceRestrictFilters(
|
$potentialOrderMatches->forceRestrictFilters(
|
||||||
[
|
[
|
||||||
// This makes sure that the user can only see department matches that belong to their departments
|
// This makes sure that the user can only see department matches that belong to their departments
|
||||||
'department' => $user->getGroup()->getDepartments()
|
'department' => $this->effectiveDepartmentIds($user)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class statisticsRoute
|
|||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_BOOKINGS', 'User requested new bookings');
|
(new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_BOOKINGS', 'User requested new bookings');
|
||||||
$statics = new statistics();
|
$statics = new statistics();
|
||||||
@@ -33,7 +34,7 @@ class statisticsRoute
|
|||||||
$response->success(
|
$response->success(
|
||||||
$statics
|
$statics
|
||||||
->bookings()
|
->bookings()
|
||||||
->get_new_bookings()
|
->get_new_bookings($departmentScope)
|
||||||
->get_response()
|
->get_response()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -60,6 +61,7 @@ class statisticsRoute
|
|||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
|
||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_ORDERS', 'User requested new orders');
|
(new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_ORDERS', 'User requested new orders');
|
||||||
$statics = new statistics();
|
$statics = new statistics();
|
||||||
@@ -67,7 +69,7 @@ class statisticsRoute
|
|||||||
$response->success(
|
$response->success(
|
||||||
$statics
|
$statics
|
||||||
->orders()
|
->orders()
|
||||||
->get_new_orders()
|
->get_new_orders($departmentScope)
|
||||||
->get_response()
|
->get_response()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -741,25 +741,36 @@ class subusersRoute
|
|||||||
return $remoteAddress !== '' ? $remoteAddress : 'unknown';
|
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;
|
global $response;
|
||||||
|
|
||||||
if (!defined('redis')) {
|
if (!defined('redis')) {
|
||||||
|
if (PHP_SAPI === 'cli') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
$response->error('Request protection is temporarily unavailable.', 503);
|
||||||
|
}
|
||||||
|
|
||||||
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
|
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
|
||||||
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
|
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
|
||||||
'sha256',
|
'sha256',
|
||||||
$this->clientThrottleIp() . ':' . $identifier
|
($includeClientIp ? $this->clientThrottleIp() . ':' : '') . $identifier
|
||||||
);
|
);
|
||||||
$redis = constant('redis');
|
try {
|
||||||
$attempts = (int)($redis->get($key) ?? '0');
|
$attempts = constant('redis')->incrementWithExpiration($key, $windowSeconds);
|
||||||
if ($attempts >= $limit) {
|
} 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);
|
$response->error('Too many attempts. Please wait and try again.', 429);
|
||||||
}
|
}
|
||||||
$redis->setEx($key, (string)($attempts + 1), $windowSeconds);
|
|
||||||
return $key;
|
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
|
private function deliverSms(?string $destination, string $message): array
|
||||||
{
|
{
|
||||||
if ($destination === null || trim($destination) === '') {
|
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
|
public function run(): void
|
||||||
{
|
{
|
||||||
subusers_schema_bootstrap::ensureTables();
|
subusers_schema_bootstrap::ensureTables();
|
||||||
@@ -2016,78 +2184,7 @@ class subusersRoute
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->post('/subusers', function () {
|
$this->post('/subusers', function () {
|
||||||
global /** @var response $response */
|
$this->registerPublicSubuser();
|
||||||
$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->get('/subusers/setup', function () {
|
$this->get('/subusers/setup', function () {
|
||||||
// Require the user to be logged in
|
// Require the user to be logged in
|
||||||
@@ -2158,8 +2255,40 @@ class subusersRoute
|
|||||||
...(!empty($email) ? ['email' => $email] : []),
|
...(!empty($email) ? ['email' => $email] : []),
|
||||||
];
|
];
|
||||||
$subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $setupUpdates));
|
$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
|
// Invalidate the setup token
|
||||||
$subuser->invalidateSetupToken($token);
|
$subuser->invalidateSetupToken($token);
|
||||||
|
$this->clearPublicRegistrationPending($token);
|
||||||
$this->clearThrottleAttempt($setupThrottleKey);
|
$this->clearThrottleAttempt($setupThrottleKey);
|
||||||
$response->success(['message' => 'Complete registration successful']);
|
$response->success(['message' => 'Complete registration successful']);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -2916,82 +3045,7 @@ class subusersRoute
|
|||||||
]);
|
]);
|
||||||
// Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me
|
// Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me
|
||||||
$this->post('/subusers/me', function () {
|
$this->post('/subusers/me', function () {
|
||||||
global /** @var response $response */
|
$this->registerPublicSubuser();
|
||||||
$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,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,11 +511,7 @@ class systemSearchRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$departments = $user->getGroup()->getDepartments();
|
return $this->effectiveDepartmentIds($user);
|
||||||
if (!is_array($departments)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return array_values(array_unique(array_map('intval', $departments)));
|
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class vehiclePlateLastOrdersRoute
|
|||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
|
||||||
// Make sure the vehicle plate is set
|
// Make sure the vehicle plate is set
|
||||||
if (!(string)$this->fromRequest('plate')) {
|
if (!(string)$this->fromRequest('plate')) {
|
||||||
$response->error('Plate parameter is required', 400);
|
$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');
|
(new logs_o())->add('orders', 'global', 1, $user->id, 'FETCH_VEHICLE_ORDER_HISTORY', 'Successfully fetched vehicle order history');
|
||||||
// Return the list of departments
|
// Return the list of departments
|
||||||
$response->success(
|
$response->success(
|
||||||
(new orders_o())->getOrderHistoryByVehiclePlate($this->fromRequest('plate'), 5)
|
(new orders_o())->getOrderHistoryByVehiclePlate(
|
||||||
|
$this->fromRequest('plate'),
|
||||||
|
5,
|
||||||
|
$departmentScope
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Log the incident
|
// Log the incident
|
||||||
|
|||||||
@@ -23,13 +23,17 @@ class vehiclePlateLookupRoute
|
|||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
|
||||||
// Make sure the vehicle plate is set
|
// Make sure the vehicle plate is set
|
||||||
if (!(string)$this->fromRequest('plate')) {
|
if (!(string)$this->fromRequest('plate')) {
|
||||||
$response->error('Plate parameter is required', 400);
|
$response->error('Plate parameter is required', 400);
|
||||||
}
|
}
|
||||||
// Return the vehicle order history
|
// Return the vehicle order history
|
||||||
$response->success(
|
$response->success(
|
||||||
(new orders_o())->get_vehicle_order_history($this->fromRequest('plate'))
|
(new orders_o())->get_vehicle_order_history(
|
||||||
|
$this->fromRequest('plate'),
|
||||||
|
$departmentScope
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Log the incident
|
// Log the incident
|
||||||
@@ -51,6 +55,7 @@ class vehiclePlateLookupRoute
|
|||||||
$user = (new authentication())->get_user();
|
$user = (new authentication())->get_user();
|
||||||
// Check if the request was successful
|
// Check if the request was successful
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
|
||||||
// Make sure the vehicle plate is set
|
// Make sure the vehicle plate is set
|
||||||
self::requireParameters(['plate', 'customer_number', 'column']);
|
self::requireParameters(['plate', 'customer_number', 'column']);
|
||||||
self::requireType((string)self::getParameter('plate'), 'string');
|
self::requireType((string)self::getParameter('plate'), 'string');
|
||||||
@@ -65,6 +70,13 @@ class vehiclePlateLookupRoute
|
|||||||
if (!in_array((string)self::getParameter('column'), $allowedColumns)) {
|
if (!in_array((string)self::getParameter('column'), $allowedColumns)) {
|
||||||
$response->error('Invalid column, allowed columns are: ' . implode(', ', $allowedColumns), 400);
|
$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
|
// Return the vehicle order history
|
||||||
$response->success(
|
$response->success(
|
||||||
(new orders_o())
|
(new orders_o())
|
||||||
@@ -73,7 +85,7 @@ class vehiclePlateLookupRoute
|
|||||||
1,
|
1,
|
||||||
10,
|
10,
|
||||||
(string)self::getParameter('plate'),
|
(string)self::getParameter('plate'),
|
||||||
['customer_id' => self::getParameter('customer_number')],
|
$filters,
|
||||||
['created_at' => 'DESC'],
|
['created_at' => 'DESC'],
|
||||||
function ($order) {
|
function ($order) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ class bookings_s
|
|||||||
{
|
{
|
||||||
use statistic_t;
|
use statistic_t;
|
||||||
|
|
||||||
public function get_new_bookings(): self
|
/**
|
||||||
|
* @param array<int, int>|null $departmentIds Null keeps the existing unrestricted contract.
|
||||||
|
*/
|
||||||
|
public function get_new_bookings(?array $departmentIds = null): self
|
||||||
{
|
{
|
||||||
// Get the departments
|
// Get the departments
|
||||||
$departments_o = new \objects\departments_o();
|
$departments_o = new \objects\departments_o();
|
||||||
@@ -16,9 +19,19 @@ class bookings_s
|
|||||||
$bookings_o = new \objects\bookings_o();
|
$bookings_o = new \objects\bookings_o();
|
||||||
|
|
||||||
// Get the bookings
|
// 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
|
// 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
|
// For each year, add the months covered
|
||||||
$this->add_monthly_labels();
|
$this->add_monthly_labels();
|
||||||
// Add the counts for each department, for each month in each year
|
// Add the counts for each department, for each month in each year
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ class orders_s
|
|||||||
{
|
{
|
||||||
use statistic_t;
|
use statistic_t;
|
||||||
|
|
||||||
public function get_new_orders(): self
|
/**
|
||||||
|
* @param array<int, int>|null $departmentIds Null keeps the existing unrestricted contract.
|
||||||
|
*/
|
||||||
|
public function get_new_orders(?array $departmentIds = null): self
|
||||||
{
|
{
|
||||||
// Get the departments
|
// Get the departments
|
||||||
$departments_o = new \objects\departments_o();
|
$departments_o = new \objects\departments_o();
|
||||||
@@ -16,9 +19,19 @@ class orders_s
|
|||||||
$orders_o = new \objects\orders_o();
|
$orders_o = new \objects\orders_o();
|
||||||
|
|
||||||
// Get the orders
|
// 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
|
// 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
|
// For each year, add the months covered
|
||||||
$this->add_monthly_labels();
|
$this->add_monthly_labels();
|
||||||
// Add the counts for each department, for each month in each year
|
// Add the counts for each department, for each month in each year
|
||||||
|
|||||||
@@ -93,6 +93,16 @@ it('sets and applies department-specific customer discounts without legacy fallb
|
|||||||
'department_id' => $fixture['department']['id'],
|
'department_id' => $fixture['department']['id'],
|
||||||
'user_id' => $fixture['customer']['id'],
|
'user_id' => $fixture['customer']['id'],
|
||||||
'overrides' => [
|
'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,
|
'is_category' => false,
|
||||||
'product_or_category_id' => $fixture['product']['id'],
|
'product_or_category_id' => $fixture['product']['id'],
|
||||||
@@ -107,7 +117,12 @@ it('sets and applies department-specific customer discounts without legacy fallb
|
|||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess();
|
->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);
|
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(750);
|
||||||
|
|
||||||
$byCustomerNumber = api_client()->get(
|
$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);
|
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 {
|
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('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
|
||||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
|
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
|
||||||
|
|||||||
@@ -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))));
|
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
|
function limited_backoffice_all_role_permissions(): array
|
||||||
{
|
{
|
||||||
return [
|
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);
|
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 {
|
it('returns saved prices and collapses legacy duplicate department price rows', function (): void {
|
||||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'legacy duplicates');
|
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);
|
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates reusable QR login links for active scoped employees', function (): void {
|
it('retires reusable QR login links in favor of one-time grants', function (): void {
|
||||||
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'happy');
|
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'retired');
|
||||||
|
|
||||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Department']);
|
api_client()->post('/limited-backoffice/employees/1/login-link')
|
||||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
->assertStatus(410)
|
||||||
|
|
||||||
$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)
|
|
||||||
->assertEnvelope()
|
->assertEnvelope()
|
||||||
->assertSuccess();
|
->assertSuccess(false)
|
||||||
|
->assertMessage('Reusable employee login links are retired. Create a short-lived one-time login grant instead.');
|
||||||
$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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates, exchanges once, idempotently guards, and revokes scoped employee login grants', function (): void {
|
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);
|
->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 {
|
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('GET /users', 'limited backoffice employee list');
|
||||||
api_test_covers('PUT /users', 'limited backoffice guard');
|
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()
|
->assertEnvelope()
|
||||||
->assertSuccess();
|
->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']]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('applies separate IP and identity throttles to public customer registration', function (): void {
|
||||||
|
$code = preg_replace(
|
||||||
|
'/\s+/',
|
||||||
|
' ',
|
||||||
|
(string)file_get_contents(app_path('routes/authRoute.php'))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($code)->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)");
|
||||||
|
});
|
||||||
+1
-1
@@ -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("hasPermission('edit_department_daily_report_complaints')");
|
||||||
expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))");
|
expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))");
|
||||||
expect($content)->toContain('requireDepartmentAccess((int)$complaint->department_id->value())');
|
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("getOrImportCustomerByCustomerNumber");
|
||||||
expect($content)->toContain("Search must be at least 2 characters");
|
expect($content)->toContain("Search must be at least 2 characters");
|
||||||
expect($content)->toContain("Failed to fetch complaint customers from e-conomic");
|
expect($content)->toContain("Failed to fetch complaint customers from e-conomic");
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('uses authoritative managed scope across every limited-role list surface', function (): void {
|
||||||
|
$routes = [
|
||||||
|
'ordersRoute.php',
|
||||||
|
'bookingsRoute.php',
|
||||||
|
'orderBookingRoute.php',
|
||||||
|
'departmentTimeBookingsRoute.php',
|
||||||
|
'departmentDailyReportsRoute.php',
|
||||||
|
'plateScansRoute.php',
|
||||||
|
'potentialOrderMatchesRoute.php',
|
||||||
|
'systemSearchRoute.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$code = (string)file_get_contents(app_path('routes/' . $route));
|
||||||
|
expect($code, $route)->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()');
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
function public_subuser_registration_method(): string
|
||||||
|
{
|
||||||
|
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
|
||||||
|
$start = strpos($code, 'private function registerPublicSubuser(): void');
|
||||||
|
$end = strpos($code, 'public function run(): void', $start === false ? 0 : $start);
|
||||||
|
|
||||||
|
if ($start === false || $end === false || $end <= $start) {
|
||||||
|
throw new RuntimeException('Unable to locate the public subuser registration handler.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($code, $start, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('routes both public driver registration aliases through one canonical handler', function (): void {
|
||||||
|
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
|
||||||
|
$normalized = preg_replace('/\s+/', ' ', $code);
|
||||||
|
|
||||||
|
expect($normalized)->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('));
|
||||||
|
});
|
||||||
@@ -39,7 +39,8 @@ it('delivers purpose-bound SMS notifications for grant requests and decisions',
|
|||||||
expect($normalized)->toContain('PURPOSE_GRANT_DENY');
|
expect($normalized)->toContain('PURPOSE_GRANT_DENY');
|
||||||
expect($normalized)->toContain('notifySubuserGrantDecision(');
|
expect($normalized)->toContain('notifySubuserGrantDecision(');
|
||||||
expect($normalized)->toContain("'decision_notification' => \$delivery");
|
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 {
|
it('keeps forgot-password responses generic and protects reset tokens by purpose', function (): void {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace {
|
|||||||
$DEBUG = true;
|
$DEBUG = true;
|
||||||
$_SERVER['REQUEST_URI'] = '/auth/register/cvr';
|
$_SERVER['REQUEST_URI'] = '/auth/register/cvr';
|
||||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace classes {
|
namespace classes {
|
||||||
@@ -189,15 +190,20 @@ namespace classes {
|
|||||||
{
|
{
|
||||||
public static array $sent = [];
|
public static array $sent = [];
|
||||||
public static array $superuser_notifications = [];
|
public static array $superuser_notifications = [];
|
||||||
|
public static array $failing_recipients = [];
|
||||||
|
|
||||||
public static function reset(): void
|
public static function reset(): void
|
||||||
{
|
{
|
||||||
self::$sent = [];
|
self::$sent = [];
|
||||||
self::$superuser_notifications = [];
|
self::$superuser_notifications = [];
|
||||||
|
self::$failing_recipients = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendWelcomeEmailToCustomer($phone, $email): bool
|
public function sendWelcomeEmailToCustomer($phone, $email): bool
|
||||||
{
|
{
|
||||||
|
if (in_array((string)$email, self::$failing_recipients, true)) {
|
||||||
|
throw new \RuntimeException('Mock welcome delivery failure');
|
||||||
|
}
|
||||||
self::$sent[] = [
|
self::$sent[] = [
|
||||||
'customer_number' => (int)$phone,
|
'customer_number' => (int)$phone,
|
||||||
'email' => (string)$email,
|
'email' => (string)$email,
|
||||||
@@ -254,6 +260,7 @@ namespace objects {
|
|||||||
public static array $mock_importable_customer_numbers = [];
|
public static array $mock_importable_customer_numbers = [];
|
||||||
public static bool $mock_external_lookup_enabled = true;
|
public static bool $mock_external_lookup_enabled = true;
|
||||||
public static array $interaction_log = [];
|
public static array $interaction_log = [];
|
||||||
|
public static array $hydrated_contacts = [];
|
||||||
|
|
||||||
public int $id = 0;
|
public int $id = 0;
|
||||||
private bool $exists = false;
|
private bool $exists = false;
|
||||||
@@ -264,6 +271,7 @@ namespace objects {
|
|||||||
self::$mock_importable_customer_numbers = [];
|
self::$mock_importable_customer_numbers = [];
|
||||||
self::$mock_external_lookup_enabled = true;
|
self::$mock_external_lookup_enabled = true;
|
||||||
self::$interaction_log = [];
|
self::$interaction_log = [];
|
||||||
|
self::$hydrated_contacts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFieldsWhere(array $fieldsAndValues, array $fields): array
|
public function getFieldsWhere(array $fieldsAndValues, array $fields): array
|
||||||
@@ -318,6 +326,22 @@ namespace objects {
|
|||||||
{
|
{
|
||||||
return $this->exists;
|
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
|
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<string, mixed> $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();
|
$router = new MockRouter();
|
||||||
$response = new \classes\response();
|
$response = new \classes\response();
|
||||||
|
|
||||||
@@ -423,6 +475,8 @@ namespace {
|
|||||||
'invoiceEmail' => 'test@test.com',
|
'invoiceEmail' => 'test@test.com',
|
||||||
'contactEmail' => 'test@test.com',
|
'contactEmail' => 'test@test.com',
|
||||||
'contactPhone' => 12345678,
|
'contactPhone' => 12345678,
|
||||||
|
'contactPhoneCountryCode' => 45,
|
||||||
|
'contactName' => 'Test Contact',
|
||||||
'g_recaptcha_response' => 'valid',
|
'g_recaptcha_response' => 'valid',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -544,6 +598,13 @@ namespace {
|
|||||||
'expected_status' => 200,
|
'expected_status' => 200,
|
||||||
'assert' => static function (): void {
|
'assert' => static function (): void {
|
||||||
assert_true(count(\classes\economic::$create_calls) === 0, 'Recovery must not create a second e-conomic customer.');
|
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(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(\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.');
|
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]['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]['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_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'];
|
$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(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.');
|
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.');
|
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',
|
'name' => 'Successful registration falls back to the create response when immediate import lookup misses',
|
||||||
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
|
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
|
||||||
@@ -749,6 +840,7 @@ namespace {
|
|||||||
\classes\virkdata::$mock_exception = null;
|
\classes\virkdata::$mock_exception = null;
|
||||||
\objects\users_o::reset();
|
\objects\users_o::reset();
|
||||||
\objects\logs_o::reset();
|
\objects\logs_o::reset();
|
||||||
|
reset_registration_throttles($test['params']);
|
||||||
|
|
||||||
if (isset($test['setup'])) {
|
if (isset($test['setup'])) {
|
||||||
$test['setup']();
|
$test['setup']();
|
||||||
|
|||||||
@@ -267,6 +267,18 @@ trait route_t
|
|||||||
*/
|
*/
|
||||||
public function requireDepartmentAccess(string $department, string|null $permission = null): void
|
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 : ''));
|
self::requirePermission('department_access_' . $department . ($permission ? '_' . $permission : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,9 +291,80 @@ trait route_t
|
|||||||
*/
|
*/
|
||||||
public function hasDepartmentAccess(string $department, string|null $permission = null): bool
|
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 : ''));
|
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<int, int>|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<int, int>
|
||||||
|
*/
|
||||||
|
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
|
* Get parameters as an array from the request
|
||||||
* @return array
|
* @return array
|
||||||
|
|||||||
Reference in New Issue
Block a user