Add limited backoffice functionality with employee management and department pricing
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_exception extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly int $statusCode = 400,
|
||||
private readonly ?array $payload = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function payload(): array|string
|
||||
{
|
||||
return $this->payload ?? $this->getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`managed_group_id` INT NOT NULL,
|
||||
`role_key` VARCHAR(64) NOT NULL,
|
||||
`department_ids` LONGTEXT NOT NULL,
|
||||
`created_by_user_id` INT NOT NULL,
|
||||
`updated_by_user_id` INT NULL,
|
||||
`deactivated_at` DATETIME NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
|
||||
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
|
||||
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\limited_backoffice_exception;
|
||||
use classes\limited_backoffice_service;
|
||||
use traits\route_t;
|
||||
|
||||
class limitedBackofficeRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/limited-backoffice/departments', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
return $service->departmentsForUser($user);
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
]);
|
||||
|
||||
$this->get('/limited-backoffice/departments/{departmentId}/prices', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$departmentId = $this->routePositiveInt('departmentId');
|
||||
return $service->getDepartmentPrices($user, $departmentId);
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
]);
|
||||
|
||||
$this->put('/limited-backoffice/departments/{departmentId}/prices', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_PRICES);
|
||||
$departmentId = $this->routePositiveInt('departmentId');
|
||||
return $service->updateDepartmentPrices($user, $departmentId, $this->requestPayload());
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_PRICES => 'Manage limited backoffice department prices',
|
||||
]);
|
||||
|
||||
$this->get('/limited-backoffice/roles', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->rolePresets();
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->get('/limited-backoffice/employees', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
$includeInactive = strtolower((string)($this->fromQuery('include_inactive') ?? 'false')) === 'true';
|
||||
return $service->employeesForUser($user, $includeInactive);
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->post('/limited-backoffice/employees', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->createEmployee($user, $this->requestPayload());
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->put('/limited-backoffice/employees/{employeeId}', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->updateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload());
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
|
||||
$this->delete('/limited-backoffice/employees/{employeeId}', function () {
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
|
||||
return $service->deactivateEmployee($user, $this->routePositiveInt('employeeId'));
|
||||
});
|
||||
}, [
|
||||
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
|
||||
]);
|
||||
}
|
||||
|
||||
private function withLimitedBackoffice(callable $callback): void
|
||||
{
|
||||
global $response;
|
||||
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$response->success($callback(new limited_backoffice_service(), $user));
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$response->error($exception->payload(), $exception->statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function requestPayload(): array
|
||||
{
|
||||
$payload = json_decode(file_get_contents('php://input'), true);
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
private function routePositiveInt(string $name): int
|
||||
{
|
||||
$value = $this->fromRoute($name);
|
||||
if (!is_string($value) || !ctype_digit($value) || (int)$value <= 0) {
|
||||
throw new limited_backoffice_exception('Invalid route parameter.', 400);
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use classes\limited_backoffice_service;
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function limited_backoffice_manager_session(array $departmentIds, array $extraPermissions = []): array
|
||||
{
|
||||
$permissions = [
|
||||
limited_backoffice_service::PERMISSION_ACCESS,
|
||||
limited_backoffice_service::PERMISSION_MANAGE_PRICES,
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
|
||||
];
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
$permissions[] = 'department_access_' . (int)$departmentId;
|
||||
}
|
||||
|
||||
return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions))));
|
||||
}
|
||||
|
||||
function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
|
||||
);
|
||||
$statement->bind_param('iii', $departmentId, $productId, $price);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
|
||||
'department_id' => $departmentId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
function limited_backoffice_price_value(int $departmentId, int $productId): ?int
|
||||
{
|
||||
$row = api_test_runtime()->queryOne(
|
||||
'SELECT `price` FROM `product_department_prices` WHERE `department_id` = ' . $departmentId .
|
||||
' AND `product_id` = ' . $productId . ' LIMIT 1'
|
||||
);
|
||||
|
||||
return $row === null ? null : (int)$row['price'];
|
||||
}
|
||||
|
||||
function limited_backoffice_cleanup_created_employee(int $employeeId): void
|
||||
{
|
||||
$row = api_test_runtime()->queryOne(
|
||||
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
|
||||
);
|
||||
$groupId = (int)($row['managed_group_id'] ?? 0);
|
||||
|
||||
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => $employeeId]);
|
||||
api_fixtures()->cleanupDeleteWhere('tokens', ['user_id' => $employeeId]);
|
||||
api_fixtures()->cleanupDeleteById('users', $employeeId);
|
||||
if ($groupId > 0) {
|
||||
api_fixtures()->cleanupDeleteWhere('groups_permissions', ['group_id' => $groupId]);
|
||||
api_fixtures()->cleanupDeleteById('groups', $groupId);
|
||||
}
|
||||
}
|
||||
|
||||
it('lists and updates explicit prices only for assigned departments', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments', 'happy');
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Prices Own']);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Prices Other']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Washes']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Limited Wash Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 98765,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 1234);
|
||||
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$departments = api_client()->get('/limited-backoffice/departments', $session['headers']);
|
||||
$departments
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(array_column($departments->data(), 'id'))
|
||||
->toContain((int)$department['id'])
|
||||
->not->toContain((int)$otherDepartment['id']);
|
||||
|
||||
$prices = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||
$prices
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($prices->body)->not->toContain('98765');
|
||||
expect($prices->data()['categories'][0]['products'][0])
|
||||
->toMatchArray([
|
||||
'id' => (int)$product['id'],
|
||||
'name' => 'Limited Wash Product',
|
||||
'price' => 1234,
|
||||
]);
|
||||
|
||||
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$product['id'], 'price' => '2222'],
|
||||
],
|
||||
], $session['headers']);
|
||||
|
||||
$updated
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(2222);
|
||||
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$product['id']))->toBe(4321);
|
||||
});
|
||||
|
||||
it('rejects cross-department price access, body spoofing, and outside products', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'auth');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Scope Own']);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Scope Other']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Scope Category']);
|
||||
$otherCategory = api_fixtures()->createCategory(['name' => 'Limited Scope Other Category']);
|
||||
$product = api_fixtures()->createProduct(['category' => $category['id']]);
|
||||
$outsideProduct = api_fixtures()->createProduct(['category' => $otherCategory['id']]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$otherCategory['id']);
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100);
|
||||
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$outsideProduct['id'], 200);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
api_client()->get('/limited-backoffice/departments/' . (int)$otherDepartment['id'] . '/prices', $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||
|
||||
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'department_id' => (int)$otherDepartment['id'],
|
||||
'prices' => [
|
||||
['product_id' => (int)$product['id'], 'price' => 111],
|
||||
],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Department ID in body does not match the route.');
|
||||
|
||||
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$product['id'], 'price' => 111],
|
||||
['product_id' => (int)$outsideProduct['id'], 'price' => 111],
|
||||
],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Product is not available for this department.');
|
||||
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(100);
|
||||
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$outsideProduct['id']))->toBe(200);
|
||||
});
|
||||
|
||||
it('fails price setup gaps without exposing product defaults', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'failure');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Setup Gap']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Setup Gap Category']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Setup Gap Product',
|
||||
'category' => $category['id'],
|
||||
'price' => 87654,
|
||||
]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
|
||||
$response
|
||||
->assertStatus(409)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Department price setup is incomplete.');
|
||||
|
||||
expect($response->body)->not->toContain('87654');
|
||||
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
|
||||
});
|
||||
|
||||
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
|
||||
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Invalid Prices']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Limited Invalid Prices Category']);
|
||||
$firstProduct = api_fixtures()->createProduct(['category' => $category['id']]);
|
||||
$secondProduct = api_fixtures()->createProduct(['category' => $category['id']]);
|
||||
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$firstProduct['id'], 100);
|
||||
limited_backoffice_price_insert((int)$department['id'], (int)$secondProduct['id'], 200);
|
||||
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
foreach ([null, '', 'abc', -1] as $invalidPrice) {
|
||||
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$firstProduct['id'], 'price' => 999],
|
||||
['product_id' => (int)$secondProduct['id'], 'price' => $invalidPrice],
|
||||
],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200);
|
||||
}
|
||||
|
||||
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
|
||||
'prices' => [
|
||||
['product_id' => (int)$firstProduct['id'], 'price' => 999],
|
||||
],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Price is required for every department product.');
|
||||
|
||||
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
|
||||
});
|
||||
|
||||
it('creates updates lists and deactivates scoped employees without exposing raw permissions', function (): void {
|
||||
api_test_covers('GET /limited-backoffice/roles', 'happy');
|
||||
api_test_covers('GET /limited-backoffice/employees', 'happy');
|
||||
api_test_covers('POST /limited-backoffice/employees', 'happy');
|
||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'happy');
|
||||
api_test_covers('DELETE /limited-backoffice/employees/{employeeId}', 'happy');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
|
||||
$roles
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
|
||||
expect($roles->body)->not->toContain('department_access_');
|
||||
|
||||
$created = api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Limited Cashier',
|
||||
'email' => 'limited-cashier@example.test',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
|
||||
$created
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$employeeId = (int)($created->data()['id'] ?? 0);
|
||||
expect($employeeId)->toBeGreaterThan(0);
|
||||
limited_backoffice_cleanup_created_employee($employeeId);
|
||||
expect($created->body)->not->toContain('department_access_');
|
||||
expect($created->body)->not->toContain('permissions');
|
||||
|
||||
$groupRow = api_test_runtime()->queryOne(
|
||||
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
|
||||
);
|
||||
$groupId = (int)($groupRow['managed_group_id'] ?? 0);
|
||||
$permissionRows = api_test_runtime()->db()->query(
|
||||
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $groupId
|
||||
)->fetch_all(MYSQLI_ASSOC);
|
||||
$permissions = array_column($permissionRows, 'permission');
|
||||
expect($permissions)
|
||||
->toContain('department_access_' . (int)$department['id'])
|
||||
->toContain('add_order')
|
||||
->not->toContain('superuser');
|
||||
|
||||
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
|
||||
'display_name' => 'Limited Lead',
|
||||
'role_key' => 'operations_lead',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers']);
|
||||
$updated
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
|
||||
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
|
||||
|
||||
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
|
||||
$ids = array_column($list->data(), 'id');
|
||||
expect($ids)->toContain($employeeId);
|
||||
expect($list->body)->not->toContain('department_access_');
|
||||
|
||||
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
|
||||
$deactivated
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($deactivated->data()['active'] ?? true)->toBeFalse();
|
||||
$userRow = api_test_runtime()->queryOne('SELECT `password`, `group_id`, `deleted_at` FROM `users` WHERE `id` = ' . $employeeId);
|
||||
expect($userRow['password'] ?? 'not-null')->toBeNull();
|
||||
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
|
||||
expect($userRow['deleted_at'] ?? null)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
|
||||
api_test_covers('POST /limited-backoffice/employees', 'validation');
|
||||
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Own']);
|
||||
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Employee Other']);
|
||||
$session = limited_backoffice_manager_session([(int)$department['id']]);
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Outside Employee',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$otherDepartment['id']],
|
||||
], $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Raw Employee',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'cashier',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
'permissions' => ['superuser'],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Raw permission and group assignment is not allowed.');
|
||||
|
||||
api_client()->post('/limited-backoffice/employees', [
|
||||
'display_name' => 'Unknown Role Employee',
|
||||
'password' => 'Secret123!',
|
||||
'role_key' => 'superuser',
|
||||
'department_ids' => [(int)$department['id']],
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Unknown role.');
|
||||
|
||||
api_client()->put('/limited-backoffice/employees/' . (int)$session['user']['id'], [
|
||||
'display_name' => 'Self Edit',
|
||||
], $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Managers cannot edit themselves.');
|
||||
|
||||
$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()->put('/limited-backoffice/employees/' . (int)$superuser['id'], [
|
||||
'display_name' => 'Edited Superuser',
|
||||
], $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()->put('/limited-backoffice/employees/' . (int)$firstSharedUser['id'], [
|
||||
'display_name' => 'Edited Shared Group',
|
||||
], $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Cannot manage shared groups.');
|
||||
});
|
||||
@@ -1748,6 +1748,7 @@ final class ApiFixtures
|
||||
$this->deleteWhereIfPossible('tokens', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('limited_backoffice_employees', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('customer_default_department', ['customer_number' => $customerNumber]);
|
||||
$this->deleteWhereIfPossible('customer_fixed_pricing', ['customer_number' => $customerNumber]);
|
||||
$this->deleteWhereIfPossible('customer_fixed_pricing_versions', ['customer_number' => $customerNumber]);
|
||||
|
||||
@@ -426,6 +426,25 @@ CREATE TABLE IF NOT EXISTS `product_department_prices` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'limited_backoffice_employees' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`managed_group_id` INT NOT NULL,
|
||||
`role_key` VARCHAR(64) NOT NULL,
|
||||
`department_ids` LONGTEXT NOT NULL,
|
||||
`created_by_user_id` INT NOT NULL,
|
||||
`updated_by_user_id` INT NULL,
|
||||
`deactivated_at` DATETIME NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
|
||||
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
|
||||
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'collected_order_invoices' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `collected_order_invoices` (
|
||||
|
||||
Reference in New Issue
Block a user