From 6b4b55cb626636cc8dee72b18957fa60da4b957c Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 7 Jul 2026 00:10:22 +0200 Subject: [PATCH] Add limited backoffice employee migration --- .../classes/limited_backoffice_service.php | 398 ++++++++++++++++-- .../app/routes/limitedBackofficeRoute.php | 9 + .../tests/Api/LimitedBackofficeApiTest.php | 345 ++++++++++++++- 3 files changed, 713 insertions(+), 39 deletions(-) diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index 4183d82e..67aa895f 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -14,14 +14,18 @@ class limited_backoffice_service public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage'; private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data'; + private const MANAGED_EMPLOYEE_CUSTOMER_NUMBER = 0; /** - * Permissions required for managed employees to sign in and appear in the employee login picker. + * Permissions required for managed employees to sign in, appear in the employee login picker, + * and open the department admin shell used by their scoped role permissions. * * @var array */ private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [ + 'admin', 'user', + 'permissions_list_own', self::PERMISSION_PUBLIC_EMPLOYEE_DATA, ]; @@ -30,8 +34,8 @@ class limited_backoffice_service */ private const ROLE_PRESETS = [ 'viewer' => [ - 'label' => 'Viewer', - 'description' => 'Can sign in and view assigned department data.', + 'label' => 'Deactivated', + 'description' => 'Keeps the employee registered without order, booking, or management permissions.', 'permissions' => [ 'user', 'permissions_list_own', @@ -39,18 +43,52 @@ class limited_backoffice_service ], 'cashier' => [ 'label' => 'Cashier', - 'description' => 'Can work with orders and order lines for assigned departments.', + 'description' => 'Can work with POS orders, products, customers, vehicles, attachments, payments, scanners, and bookings for assigned departments.', 'permissions' => [ 'user', 'permissions_list_own', 'list_orders', + 'fetch_order', 'add_order', 'edit_order', + 'mark_order_as_completed', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', + 'list_order_attachments', + 'add_order_attachments', + 'download_order_attachments', + 'list_products', + 'list_categories', + 'list_department_categories', + 'list_department_order_recommended', + 'vehicle_product_suggestions', + 'search_customers', + 'get_user_from_customer_number', + 'list_customer_notes', + 'add_customer_note', + 'list_customer_attributes', + 'search_vehicles', + 'view_vehicle_status', + 'list_unknown_customer_vehicles', + 'list_vehicle_customer_suggestions', + 'department_license_plate_lookup', + 'department_vehicle_order_last_five', + 'list_number_plate_scans', + 'list_department_number_plate_scanners', 'charge_order', + 'get_payment_intent', + 'confirm_payment_intent', + 'modules_stripe_department_terminal_readers_list', + 'modules_stripe_invoice_send', + 'list_bookings', + 'list_own_bookings', + 'edit_bookings', + 'add_booking', + 'add_bookings', + 'complete_bookings', + 'resend_booking_confirmations', ], ], 'booking_coordinator' => [ @@ -64,6 +102,7 @@ class limited_backoffice_service 'list_own_bookings', 'edit_bookings', 'add_booking', + 'add_bookings', 'complete_bookings', 'resend_booking_confirmations', 'department_timebookings_entries_get', @@ -78,18 +117,46 @@ class limited_backoffice_service 'user', 'permissions_list_own', 'list_orders', + 'fetch_order', 'add_order', 'edit_order', 'delete_order', + 'mark_order_as_completed', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', + 'list_order_attachments', + 'add_order_attachments', + 'download_order_attachments', + 'list_products', + 'list_categories', + 'list_department_categories', + 'list_department_order_recommended', + 'vehicle_product_suggestions', + 'search_customers', + 'get_user_from_customer_number', + 'list_customer_notes', + 'add_customer_note', + 'list_customer_attributes', + 'search_vehicles', + 'view_vehicle_status', + 'list_unknown_customer_vehicles', + 'list_vehicle_customer_suggestions', + 'department_license_plate_lookup', + 'department_vehicle_order_last_five', + 'list_number_plate_scans', + 'list_department_number_plate_scanners', 'charge_order', + 'get_payment_intent', + 'confirm_payment_intent', + 'modules_stripe_department_terminal_readers_list', + 'modules_stripe_invoice_send', 'list_bookings', 'list_own_bookings', 'edit_bookings', 'add_booking', + 'add_bookings', 'complete_bookings', 'resend_booking_confirmations', 'statistics_orders_new', @@ -103,18 +170,46 @@ class limited_backoffice_service 'user', 'permissions_list_own', 'list_orders', + 'fetch_order', 'add_order', 'edit_order', 'delete_order', + 'mark_order_as_completed', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', + 'list_order_attachments', + 'add_order_attachments', + 'download_order_attachments', + 'list_products', + 'list_categories', + 'list_department_categories', + 'list_department_order_recommended', + 'vehicle_product_suggestions', + 'search_customers', + 'get_user_from_customer_number', + 'list_customer_notes', + 'add_customer_note', + 'list_customer_attributes', + 'search_vehicles', + 'view_vehicle_status', + 'list_unknown_customer_vehicles', + 'list_vehicle_customer_suggestions', + 'department_license_plate_lookup', + 'department_vehicle_order_last_five', + 'list_number_plate_scans', + 'list_department_number_plate_scanners', 'charge_order', + 'get_payment_intent', + 'confirm_payment_intent', + 'modules_stripe_department_terminal_readers_list', + 'modules_stripe_invoice_send', 'list_bookings', 'list_own_bookings', 'edit_bookings', 'add_booking', + 'add_bookings', 'complete_bookings', 'resend_booking_confirmations', 'statistics_orders_new', @@ -142,6 +237,10 @@ class limited_backoffice_service 'group' => 'orders', 'capability' => 'view_orders', ], + 'fetch_order' => [ + 'group' => 'orders', + 'capability' => 'view_orders', + ], 'add_order' => [ 'group' => 'orders', 'capability' => 'create_orders', @@ -154,6 +253,10 @@ class limited_backoffice_service 'group' => 'orders', 'capability' => 'delete_orders', ], + 'mark_order_as_completed' => [ + 'group' => 'orders', + 'capability' => 'complete_orders', + ], 'list_order_items' => [ 'group' => 'orders', 'capability' => 'view_order_items', @@ -170,10 +273,110 @@ class limited_backoffice_service 'group' => 'orders', 'capability' => 'remove_order_lines', ], + 'list_order_attachments' => [ + 'group' => 'attachments', + 'capability' => 'view_order_attachments', + ], + 'add_order_attachments' => [ + 'group' => 'attachments', + 'capability' => 'add_order_attachments', + ], + 'download_order_attachments' => [ + 'group' => 'attachments', + 'capability' => 'download_order_attachments', + ], + 'list_products' => [ + 'group' => 'products', + 'capability' => 'view_product_catalog', + ], + 'list_categories' => [ + 'group' => 'products', + 'capability' => 'view_product_catalog', + ], + 'list_department_categories' => [ + 'group' => 'products', + 'capability' => 'view_product_catalog', + ], + 'list_department_order_recommended' => [ + 'group' => 'products', + 'capability' => 'view_product_recommendations', + ], + 'vehicle_product_suggestions' => [ + 'group' => 'products', + 'capability' => 'view_product_recommendations', + ], + 'search_customers' => [ + 'group' => 'customers', + 'capability' => 'search_customers', + ], + 'get_user_from_customer_number' => [ + 'group' => 'customers', + 'capability' => 'view_customer_details', + ], + 'list_customer_notes' => [ + 'group' => 'customers', + 'capability' => 'view_customer_notes', + ], + 'add_customer_note' => [ + 'group' => 'customers', + 'capability' => 'add_customer_notes', + ], + 'list_customer_attributes' => [ + 'group' => 'customers', + 'capability' => 'view_customer_flags', + ], + 'search_vehicles' => [ + 'group' => 'vehicles', + 'capability' => 'search_vehicles', + ], + 'view_vehicle_status' => [ + 'group' => 'vehicles', + 'capability' => 'search_vehicles', + ], + 'list_unknown_customer_vehicles' => [ + 'group' => 'vehicles', + 'capability' => 'view_vehicle_matches', + ], + 'list_vehicle_customer_suggestions' => [ + 'group' => 'vehicles', + 'capability' => 'view_vehicle_matches', + ], + 'department_license_plate_lookup' => [ + 'group' => 'vehicles', + 'capability' => 'view_vehicle_history', + ], + 'department_vehicle_order_last_five' => [ + 'group' => 'vehicles', + 'capability' => 'view_vehicle_history', + ], + 'list_number_plate_scans' => [ + 'group' => 'scanner', + 'capability' => 'view_plate_scans', + ], + 'list_department_number_plate_scanners' => [ + 'group' => 'scanner', + 'capability' => 'view_plate_scans', + ], 'charge_order' => [ 'group' => 'orders', 'capability' => 'charge_orders', ], + 'get_payment_intent' => [ + 'group' => 'orders', + 'capability' => 'charge_orders', + ], + 'confirm_payment_intent' => [ + 'group' => 'orders', + 'capability' => 'charge_orders', + ], + 'modules_stripe_department_terminal_readers_list' => [ + 'group' => 'orders', + 'capability' => 'charge_orders', + ], + 'modules_stripe_invoice_send' => [ + 'group' => 'orders', + 'capability' => 'charge_orders', + ], 'list_bookings' => [ 'group' => 'bookings', 'capability' => 'view_department_bookings', @@ -190,6 +393,10 @@ class limited_backoffice_service 'group' => 'bookings', 'capability' => 'create_bookings', ], + 'add_bookings' => [ + 'group' => 'bookings', + 'capability' => 'create_bookings', + ], 'complete_bookings' => [ 'group' => 'bookings', 'capability' => 'mark_bookings_complete', @@ -238,6 +445,11 @@ class limited_backoffice_service private const ROLE_PERMISSION_GROUP_ORDER = [ 'account', 'orders', + 'products', + 'customers', + 'vehicles', + 'attachments', + 'scanner', 'bookings', 'time_bookings', 'reports', @@ -333,6 +545,15 @@ class limited_backoffice_service return []; } + if ($user->hasPermission('superuser')) { + global $db; + $rows = $db->fetch_all($db->query( + 'SELECT `id` FROM `departments` ORDER BY `id` ASC' + )); + + return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows)); + } + global $db; $statement = $this->mysqli()->prepare( 'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ?' @@ -574,7 +795,7 @@ class limited_backoffice_service try { $groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds); - $customerNumber = $this->generateEmployeeCustomerNumber(); + $customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER; $passwordHash = password_hash($password, PASSWORD_DEFAULT); $statement = $mysqli->prepare( @@ -599,15 +820,7 @@ class limited_backoffice_service $employeeId = (int)$mysqli->insert_id; $statement->close(); - $groupName = 'Limited employee #' . $employeeId; - $groupDescription = 'Managed by limited backoffice.'; - $statement = $mysqli->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1'); - if ($statement === false) { - throw new \RuntimeException('Unable to prepare group update.'); - } - $statement->bind_param('ssi', $groupName, $groupDescription, $groupId); - $statement->execute(); - $statement->close(); + $this->renameManagedGroup($groupId, $employeeId); $departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($departmentJson)) { @@ -641,6 +854,72 @@ class limited_backoffice_service return $this->formatEmployee($employee, $departmentIds, true); } + /** + * @param array $payload + * @return array + */ + public function migrateEmployee(users_o $manager, int $employeeId, array $payload): array + { + $this->rejectRawPermissionPayload($payload); + $this->assertNotSelfEdit($manager, $employeeId); + + if ($this->loadManagedEmployee($employeeId) !== null) { + throw new limited_backoffice_exception('User is already a limited backoffice employee.', 409); + } + + $target = $this->loadMigratableUser($employeeId); + if ($target === null) { + throw new limited_backoffice_exception('User not found.', 404); + } + $this->assertMigrationTargetIsSafe($target); + + $departmentIds = $this->normalizeDepartmentIds($payload['department_ids'] ?? null); + $this->assertDepartmentSubset($manager, $departmentIds); + $roleKey = $this->normalizeRoleKey($payload['role_key'] ?? null); + + $departmentJson = json_encode($departmentIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!is_string($departmentJson)) { + throw new limited_backoffice_exception('Unable to encode department metadata.', 500); + } + + $mysqli = $this->mysqli(); + $mysqli->begin_transaction(); + + try { + $groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds); + $this->renameManagedGroup($groupId, $employeeId); + $this->updateUserFields($employeeId, [ + 'group_id' => $groupId, + ]); + + $managerId = (int)$manager->id; + $statement = $mysqli->prepare( + 'INSERT INTO `limited_backoffice_employees` + (`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`, `updated_by_user_id`) + VALUES (?, ?, ?, ?, ?, ?)' + ); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare migrated employee metadata insert.'); + } + $statement->bind_param('iissii', $employeeId, $groupId, $roleKey, $departmentJson, $managerId, $managerId); + $statement->execute(); + $statement->close(); + + $this->clearUserSessionCache($employeeId); + $mysqli->commit(); + } catch (\Throwable) { + $mysqli->rollback(); + throw new limited_backoffice_exception('Unable to migrate employee.', 500); + } + + $employee = $this->loadManagedEmployee($employeeId); + if ($employee === null) { + throw new limited_backoffice_exception('Unable to load migrated employee.', 500); + } + + return $this->formatEmployee($employee, $departmentIds, true); + } + /** * @param array $payload * @return array @@ -1295,6 +1574,19 @@ class limited_backoffice_service return $groupId; } + private function renameManagedGroup(int $groupId, int $employeeId): void + { + $groupName = 'Limited employee #' . $employeeId; + $groupDescription = 'Managed by limited backoffice.'; + $statement = $this->mysqli()->prepare('UPDATE `groups` SET `name` = ?, `description` = ? WHERE `id` = ? LIMIT 1'); + if ($statement === false) { + throw new \RuntimeException('Unable to prepare group update.'); + } + $statement->bind_param('ssi', $groupName, $groupDescription, $groupId); + $statement->execute(); + $statement->close(); + } + /** * @return array */ @@ -1364,29 +1656,6 @@ class limited_backoffice_service $insert->close(); } - private function generateEmployeeCustomerNumber(): int - { - $mysqli = $this->mysqli(); - for ($attempt = 0; $attempt < 20; $attempt++) { - $customerNumber = random_int(900000000, 999999999); - $statement = $mysqli->prepare('SELECT `id` FROM `users` WHERE `customer_number` = ? LIMIT 1'); - if ($statement === false) { - throw new \RuntimeException('Unable to prepare customer number check.'); - } - $statement->bind_param('i', $customerNumber); - $statement->execute(); - $result = $statement->get_result(); - $exists = $result->num_rows > 0; - $statement->close(); - - if (!$exists) { - return $customerNumber; - } - } - - throw new \RuntimeException('Unable to generate employee customer number.'); - } - /** * @return array|null */ @@ -1424,6 +1693,42 @@ class limited_backoffice_service return is_array($row) ? $row : null; } + /** + * @return array|null + */ + private function loadMigratableUser(int $employeeId): ?array + { + global $db; + $userDeletedAtSelect = $this->tableHasColumn('users', 'deleted_at') + ? 'u.`deleted_at` AS `user_deleted_at`' + : 'NULL AS `user_deleted_at`'; + + $statement = $this->mysqli()->prepare( + 'SELECT + u.`id`, + u.`customer_number`, + u.`display_name`, + u.`email`, + u.`phone_country_code`, + u.`phone`, + u.`group_id`, + ' . $userDeletedAtSelect . ' + FROM `users` u + WHERE u.`id` = ? + LIMIT 1' + ); + if ($statement === false) { + throw new limited_backoffice_exception('Unable to load user.', 500); + } + $statement->bind_param('i', $employeeId); + $statement->execute(); + $result = $statement->get_result(); + $row = $db->fetch_assoc($result); + $statement->close(); + + return is_array($row) ? $row : null; + } + /** * @param array $row */ @@ -1518,6 +1823,25 @@ class limited_backoffice_service } } + /** + * @param array $target + */ + private function assertMigrationTargetIsSafe(array $target): void + { + if ((int)($target['customer_number'] ?? -1) !== self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER) { + throw new limited_backoffice_exception('Only employee accounts with customer number 0 can be migrated.', 400); + } + + $groupId = (int)($target['group_id'] ?? 0); + if ($groupId === 1 || $this->groupHasPermission($groupId, 'superuser')) { + throw new limited_backoffice_exception('Cannot migrate superuser accounts.', 403); + } + + if (($target['user_deleted_at'] ?? null) !== null) { + throw new limited_backoffice_exception('Cannot migrate inactive users.', 409); + } + } + private function groupHasPermission(int $groupId, string $permission): bool { if ($groupId <= 0) { diff --git a/services/nginx/app/routes/limitedBackofficeRoute.php b/services/nginx/app/routes/limitedBackofficeRoute.php index 9f6a69ef..30327f34 100644 --- a/services/nginx/app/routes/limitedBackofficeRoute.php +++ b/services/nginx/app/routes/limitedBackofficeRoute.php @@ -78,6 +78,15 @@ class limitedBackofficeRoute limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees', ]); + $this->post('/limited-backoffice/employees/{employeeId}/migrate', function () { + $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { + $this->requirePermission('superuser'); + return $service->migrateEmployee($user, $this->routePositiveInt('employeeId'), $this->requestPayload()); + }); + }, [ + 'superuser' => 'Migrate existing employees to limited backoffice employees', + ]); + $this->post('/limited-backoffice/employees/{employeeId}/login-link', function () { $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index cdbe7679..d53bcda4 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -26,18 +26,46 @@ function limited_backoffice_all_role_permissions(): array 'user', 'permissions_list_own', 'list_orders', + 'fetch_order', 'add_order', 'edit_order', 'delete_order', + 'mark_order_as_completed', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', + 'list_order_attachments', + 'add_order_attachments', + 'download_order_attachments', + 'list_products', + 'list_categories', + 'list_department_categories', + 'list_department_order_recommended', + 'vehicle_product_suggestions', + 'search_customers', + 'get_user_from_customer_number', + 'list_customer_notes', + 'add_customer_note', + 'list_customer_attributes', + 'search_vehicles', + 'view_vehicle_status', + 'list_unknown_customer_vehicles', + 'list_vehicle_customer_suggestions', + 'department_license_plate_lookup', + 'department_vehicle_order_last_five', + 'list_number_plate_scans', + 'list_department_number_plate_scanners', 'charge_order', + 'get_payment_intent', + 'confirm_payment_intent', + 'modules_stripe_department_terminal_readers_list', + 'modules_stripe_invoice_send', 'list_bookings', 'list_own_bookings', 'edit_bookings', 'add_booking', + 'add_bookings', 'complete_bookings', 'resend_booking_confirmations', 'department_timebookings_entries_get', @@ -48,6 +76,24 @@ function limited_backoffice_all_role_permissions(): array ]; } +function limited_backoffice_role_preset_permissions(string $roleKey): array +{ + static $rolePresets = null; + + if ($rolePresets === null) { + $reflection = new ReflectionClass(limited_backoffice_service::class); + $constant = $reflection->getReflectionConstant('ROLE_PRESETS'); + + if (!$constant instanceof ReflectionClassConstant) { + throw new RuntimeException('Limited backoffice role presets are unavailable.'); + } + + $rolePresets = $constant->getValue(); + } + + return $rolePresets[$roleKey]['permissions'] ?? []; +} + function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void { $statement = api_test_runtime()->db()->prepare( @@ -575,6 +621,182 @@ it('rejects invalid price batches and leaves existing prices unchanged', functio expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100); }); +it('keeps higher limited backoffice roles as cashier permission supersets', function (): void { + $cashierPermissions = limited_backoffice_role_preset_permissions('cashier'); + expect($cashierPermissions)->not->toBeEmpty(); + + foreach (['operations_lead', 'department_admin'] as $roleKey) { + $rolePermissions = limited_backoffice_role_preset_permissions($roleKey); + $missingPermissions = array_values(array_diff($cashierPermissions, $rolePermissions)); + + if ($missingPermissions !== []) { + throw new RuntimeException( + $roleKey . ' must include all cashier permissions; missing: ' . implode(', ', $missingPermissions) + ); + } + } +}); + +it('lets superusers migrate existing employee accounts to limited backoffice employees', function (): void { + api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'happy'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Migration Department']); + $legacyGroup = api_fixtures()->createGroup(['name' => 'Legacy Employee Group'], [ + 'employee_public_data', + ]); + $legacyEmployee = api_fixtures()->createUser([ + 'customer_number' => 0, + 'display_name' => 'Legacy Counter Employee', + 'email' => 'legacy-counter@example.test', + 'group_id' => $legacyGroup['id'], + ]); + $superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]); + + $departments = api_client()->get('/limited-backoffice/departments', $superuserSession['headers']); + $departments + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect(array_map('intval', array_column($departments->data(), 'id')))->toContain((int)$department['id']); + + $migrated = api_client()->post('/limited-backoffice/employees/' . (int)$legacyEmployee['id'] . '/migrate', [ + 'role_key' => 'cashier', + 'department_ids' => [(int)$department['id']], + ], $superuserSession['headers']); + + $migrated + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + limited_backoffice_cleanup_created_employee((int)$legacyEmployee['id']); + expect($migrated->data()['id'] ?? null)->toBe((int)$legacyEmployee['id']); + expect($migrated->data()['customer_number'] ?? null)->toBe(0); + expect($migrated->data()['display_name'] ?? null)->toBe('Legacy Counter Employee'); + expect($migrated->data()['email'] ?? null)->toBe('legacy-counter@example.test'); + expect($migrated->data()['role']['key'] ?? null)->toBe('cashier'); + expect(array_map('intval', array_column($migrated->data()['departments'] ?? [], 'id')))->toBe([(int)$department['id']]); + + $metadata = api_test_runtime()->queryOne( + 'SELECT `managed_group_id`, `role_key`, `department_ids` + FROM `limited_backoffice_employees` + WHERE `user_id` = ' . (int)$legacyEmployee['id'] . ' LIMIT 1' + ); + expect($metadata)->not->toBeNull(); + $managedGroupId = (int)($metadata['managed_group_id'] ?? 0); + expect($managedGroupId)->toBeGreaterThan(0); + expect($managedGroupId)->not->toBe((int)$legacyGroup['id']); + expect($metadata['role_key'] ?? null)->toBe('cashier'); + expect(json_decode((string)($metadata['department_ids'] ?? '[]'), true))->toBe([(int)$department['id']]); + + $userRow = api_test_runtime()->queryOne( + 'SELECT `customer_number`, `group_id`, `display_name`, `email` + FROM `users` + WHERE `id` = ' . (int)$legacyEmployee['id'] . ' LIMIT 1' + ); + expect((int)($userRow['customer_number'] ?? -1))->toBe(0); + expect((int)($userRow['group_id'] ?? 0))->toBe($managedGroupId); + expect($userRow['display_name'] ?? null)->toBe('Legacy Counter Employee'); + expect($userRow['email'] ?? null)->toBe('legacy-counter@example.test'); + + $permissionRows = api_test_runtime()->db()->query( + 'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $managedGroupId + )->fetch_all(MYSQLI_ASSOC); + $permissions = array_column($permissionRows, 'permission'); + expect($permissions) + ->toContain('admin') + ->toContain('user') + ->toContain('permissions_list_own') + ->toContain('employee_public_data') + ->toContain('department_access_' . (int)$department['id']) + ->toContain('fetch_order') + ->toContain('search_customers') + ->toContain('add_order_attachments') + ->toContain('list_number_plate_scans') + ->toContain('add_bookings') + ->not->toContain('superuser'); + + $listed = api_client()->get('/limited-backoffice/employees', $superuserSession['headers']); + $listedIds = array_map('intval', array_column($listed->data(), 'id')); + expect($listedIds)->toContain((int)$legacyEmployee['id']); +}); + +it('rejects unsafe limited backoffice employee migrations', function (): void { + api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'auth'); + api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'validation'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Migration Rejections']); + $limitedManagerSession = limited_backoffice_manager_session([(int)$department['id']]); + $superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]); + $legacyEmployee = api_fixtures()->createUser([ + 'customer_number' => 0, + 'display_name' => 'Unsafe Migration Employee', + 'email' => 'unsafe-migration-employee@example.test', + ]); + + api_client()->post('/limited-backoffice/employees/' . (int)$legacyEmployee['id'] . '/migrate', [ + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $limitedManagerSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['superuser']); + + api_client()->post('/limited-backoffice/employees/' . (int)$superuserSession['user']['id'] . '/migrate', [ + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $superuserSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Managers cannot edit themselves.'); + + $customerUser = api_fixtures()->createUser(['customer_number' => 99112233]); + api_client()->post('/limited-backoffice/employees/' . (int)$customerUser['id'] . '/migrate', [ + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $superuserSession['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Only employee accounts with customer number 0 can be migrated.'); + + $created = api_client()->post('/limited-backoffice/employees', [ + 'display_name' => 'Already Managed Migration', + 'email' => 'already-managed-migration@example.test', + 'password' => 'Secret123!', + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $limitedManagerSession['headers']); + $alreadyManagedId = (int)($created->data()['id'] ?? 0); + expect($alreadyManagedId)->toBeGreaterThan(0); + limited_backoffice_cleanup_created_employee($alreadyManagedId); + + api_client()->post('/limited-backoffice/employees/' . $alreadyManagedId . '/migrate', [ + 'role_key' => 'cashier', + 'department_ids' => [(int)$department['id']], + ], $superuserSession['headers']) + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('User is already a limited backoffice employee.'); + + $targetSuperuser = api_fixtures()->createUser([ + 'customer_number' => 0, + 'email' => 'target-superuser-migration@example.test', + 'group_id' => 1, + ]); + api_client()->post('/limited-backoffice/employees/' . (int)$targetSuperuser['id'] . '/migrate', [ + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $superuserSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Cannot migrate superuser accounts.'); +}); + it('creates updates lists and deactivates scoped employees without exposing raw permissions', function (): void { limited_backoffice_without_users_deleted_at(function (): void { api_test_covers('GET /limited-backoffice/roles', 'happy'); @@ -598,12 +820,57 @@ it('creates updates lists and deactivates scoped employees without exposing raw expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']); $rolesByKey = array_column($roles->data(), null, 'key'); + expect($rolesByKey['viewer']['label'] ?? null)->toBe('Deactivated'); expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([ [ 'key' => 'account', 'capabilities' => ['sign_in', 'view_own_permissions'], ], ]); + $cashierGroups = array_column($rolesByKey['cashier']['permission_groups'] ?? [], 'capabilities', 'key'); + expect($cashierGroups['orders'] ?? null)->toBe([ + 'view_orders', + 'create_orders', + 'edit_orders', + 'complete_orders', + 'view_order_items', + 'create_order_items', + 'update_order_lines', + 'remove_order_lines', + 'charge_orders', + ]); + expect($cashierGroups['products'] ?? null)->toBe([ + 'view_product_catalog', + 'view_product_recommendations', + ]); + expect($cashierGroups['customers'] ?? null)->toBe([ + 'search_customers', + 'view_customer_details', + 'view_customer_notes', + 'add_customer_notes', + 'view_customer_flags', + ]); + expect($cashierGroups['vehicles'] ?? null)->toBe([ + 'search_vehicles', + 'view_vehicle_matches', + 'view_vehicle_history', + ]); + expect($cashierGroups['attachments'] ?? null)->toBe([ + 'view_order_attachments', + 'add_order_attachments', + 'download_order_attachments', + ]); + expect($cashierGroups['scanner'] ?? null)->toBe([ + 'view_plate_scans', + ]); + expect($cashierGroups['bookings'] ?? null)->toBe([ + 'view_department_bookings', + 'view_own_bookings', + 'update_bookings', + 'create_bookings', + 'mark_bookings_complete', + 'send_booking_confirmations', + ]); $departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key'); expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([ 'open_limited_backoffice', @@ -620,18 +887,46 @@ it('creates updates lists and deactivates scoped employees without exposing raw }); foreach ([ 'list_orders', + 'fetch_order', 'add_order', 'edit_order', 'delete_order', + 'mark_order_as_completed', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', + 'list_order_attachments', + 'add_order_attachments', + 'download_order_attachments', + 'list_products', + 'list_categories', + 'list_department_categories', + 'list_department_order_recommended', + 'vehicle_product_suggestions', + 'search_customers', + 'get_user_from_customer_number', + 'list_customer_notes', + 'add_customer_note', + 'list_customer_attributes', + 'search_vehicles', + 'view_vehicle_status', + 'list_unknown_customer_vehicles', + 'list_vehicle_customer_suggestions', + 'department_license_plate_lookup', + 'department_vehicle_order_last_five', + 'list_number_plate_scans', + 'list_department_number_plate_scanners', 'charge_order', + 'get_payment_intent', + 'confirm_payment_intent', + 'modules_stripe_department_terminal_readers_list', + 'modules_stripe_invoice_send', 'list_bookings', 'list_own_bookings', 'edit_bookings', 'add_booking', + 'add_bookings', 'complete_bookings', 'resend_booking_confirmations', 'department_timebookings_entries_get', @@ -665,6 +960,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); expect($created->data()['user_id'] ?? null)->toBe($employeeId); + expect($created->data()['customer_number'] ?? null)->toBe(0); expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test'); expect($created->data()['phone_country_code'] ?? null)->toBe(45); expect($created->data()['phone'] ?? null)->toBe(12345678); @@ -680,9 +976,44 @@ it('creates updates lists and deactivates scoped employees without exposing raw )->fetch_all(MYSQLI_ASSOC); $permissions = array_column($permissionRows, 'permission'); expect($permissions) + ->toContain('admin') + ->toContain('user') + ->toContain('permissions_list_own') ->toContain('department_access_' . (int)$department['id']) ->toContain('employee_public_data') ->toContain('add_order') + ->toContain('fetch_order') + ->toContain('list_products') + ->toContain('search_customers') + ->toContain('get_user_from_customer_number') + ->toContain('search_vehicles') + ->toContain('list_order_attachments') + ->toContain('add_order_attachments') + ->toContain('download_order_attachments') + ->toContain('list_number_plate_scans') + ->toContain('modules_stripe_department_terminal_readers_list') + ->toContain('list_bookings') + ->toContain('edit_bookings') + ->toContain('add_bookings') + ->not->toContain('superuser'); + + $employeeToken = api_fixtures()->createAuthToken($employeeId); + $employeeSession = api_client()->get('/auth/session', api_fixtures()->bearerHeaders($employeeToken)); + $employeeSession + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect($employeeSession->data()['permissions'] ?? []) + ->toContain('admin') + ->toContain('permissions_list_own') + ->toContain('department_access_' . (int)$department['id']) + ->toContain('add_order') + ->toContain('list_products') + ->toContain('search_customers') + ->toContain('search_vehicles') + ->toContain('add_order_attachments') + ->toContain('list_bookings') + ->toContain('edit_bookings') ->not->toContain('superuser'); $publicEmployees = api_client()->get('/public/employees'); @@ -732,8 +1063,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw ->assertSuccess(); expect($deactivated->data()['active'] ?? true)->toBeFalse(); - $userRow = api_test_runtime()->queryOne('SELECT `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId); + $userRow = api_test_runtime()->queryOne('SELECT `customer_number`, `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId); expect($userRow)->not->toBeNull(); + expect((int)($userRow['customer_number'] ?? -1))->toBe(0); expect(array_key_exists('password', $userRow ?? []))->toBeTrue(); expect($userRow['password'])->toBeNull(); expect((int)($userRow['group_id'] ?? -1))->toBe(0); @@ -759,7 +1091,7 @@ it('caps limited employee permissions to the manager permissions and selected de $roles = api_client()->get('/limited-backoffice/roles', $session['headers']); $rolesByKey = array_column($roles->data(), null, 'key'); $operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key'); - expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in']); + expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in', 'view_own_permissions']); expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']); expect($roles->body)->not->toContain('create_orders'); expect($roles->body)->not->toContain('view_order_statistics'); @@ -790,11 +1122,17 @@ it('caps limited employee permissions to the manager permissions and selected de $permissions = array_column($permissionRows, 'permission'); expect($permissions) + ->toContain('admin') ->toContain('user') + ->toContain('permissions_list_own') ->toContain('employee_public_data') ->toContain('list_orders') ->toContain('department_access_' . (int)$department['id']) ->not->toContain('add_order') + ->not->toContain('fetch_order') + ->not->toContain('list_products') + ->not->toContain('search_customers') + ->not->toContain('add_bookings') ->not->toContain('delete_order') ->not->toContain('statistics_orders_new') ->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); @@ -1001,6 +1339,7 @@ it('includes limited employees in the regular employee list and protects raw use $employeeId = (int)($created->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); + expect($created->data()['customer_number'] ?? null)->toBe(0); $adminSession = api_fixtures()->createUserSession([ 'list_users', @@ -1027,6 +1366,7 @@ it('includes limited employees in the regular employee list and protects raw use ); $customerNumber = (int)($userRow['customer_number'] ?? 0); $groupId = (int)($userRow['group_id'] ?? 0); + expect($customerNumber)->toBe(0); api_client()->put('/users', [ 'id' => $employeeId, @@ -1090,6 +1430,7 @@ it('accepts employees without optional phone details', function (): void { $employeeId = (int)($created->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); + expect($created->data()['customer_number'] ?? null)->toBe(0); expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue(); expect(array_key_exists('phone', $created->data()))->toBeTrue(); expect($created->data()['phone_country_code'])->toBeNull();