From 18fede78f8d16b1148d8caa7cc7251bf4e1dbe8e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 09:15:55 +0200 Subject: [PATCH] Fix limited backoffice schema compatibility --- .../classes/limited_backoffice_service.php | 60 ++++++++++++++++--- .../tests/Api/LimitedBackofficeApiTest.php | 39 +++++++++++- .../tests/Support/Api/ApiSchemaBootstrap.php | 4 +- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index 1517c292..d8ecd4c4 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -112,6 +112,11 @@ class limited_backoffice_service ], ]; + /** + * @var array + */ + private array $columnExistsCache = []; + public function __construct() { limited_backoffice_schema_bootstrap::ensureTables(); @@ -320,6 +325,10 @@ class limited_backoffice_service return []; } + $userDeletedAtSelect = $this->tableHasColumn('users', 'deleted_at') + ? 'u.`deleted_at` AS `user_deleted_at`' + : 'NULL AS `user_deleted_at`'; + global $db; $rows = $db->fetch_all($db->query(" SELECT @@ -330,7 +339,7 @@ class limited_backoffice_service u.`phone_country_code`, u.`phone`, u.`group_id`, - u.`deleted_at` AS `user_deleted_at` + {$userDeletedAtSelect} FROM `limited_backoffice_employees` lbe INNER JOIN `users` u ON u.`id` = lbe.`user_id` ORDER BY u.`display_name` ASC, lbe.`user_id` ASC @@ -497,13 +506,18 @@ class limited_backoffice_service if ($password !== null) { $userUpdates['password'] = password_hash($password, PASSWORD_DEFAULT); } + $usersHaveDeletedAt = $this->tableHasColumn('users', 'deleted_at'); if ($active) { $userUpdates['group_id'] = $managedGroupId; - $userUpdates['deleted_at'] = null; + if ($usersHaveDeletedAt) { + $userUpdates['deleted_at'] = null; + } } else { $userUpdates['group_id'] = 0; $userUpdates['password'] = null; - $userUpdates['deleted_at'] = date('Y-m-d H:i:s'); + if ($usersHaveDeletedAt) { + $userUpdates['deleted_at'] = date('Y-m-d H:i:s'); + } } $this->updateUserFields($employeeId, $userUpdates); @@ -554,6 +568,28 @@ class limited_backoffice_service return $db->conn(); } + private function tableHasColumn(string $table, string $column): bool + { + $cacheKey = $table . '.' . $column; + if (array_key_exists($cacheKey, $this->columnExistsCache)) { + return $this->columnExistsCache[$cacheKey]; + } + + global $db; + $tableSql = $this->escapeIdentifierLookup($table); + $columnSql = $this->escapeIdentifierLookup($column); + $result = $db->query("SHOW COLUMNS FROM `{$tableSql}` LIKE '{$columnSql}'"); + $exists = $result !== false && is_object($result) && property_exists($result, 'num_rows') && (int)$result->num_rows > 0; + + $this->columnExistsCache[$cacheKey] = $exists; + return $exists; + } + + private function escapeIdentifierLookup(string $value): string + { + return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); + } + private function assertDepartmentAccess(users_o $user, int $departmentId): void { if ($departmentId <= 0) { @@ -605,6 +641,14 @@ class limited_backoffice_service { global $db; + $where = [ + 'dc.`department_id` = ?', + 'dc.`deleted_at` IS NULL', + ]; + if ($this->tableHasColumn('products', 'deleted_at')) { + $where[] = 'p.`deleted_at` IS NULL'; + } + $statement = $this->mysqli()->prepare( 'SELECT c.`id` AS `category_id`, @@ -621,9 +665,7 @@ class limited_backoffice_service LEFT JOIN `product_department_prices` pdp ON pdp.`department_id` = dc.`department_id` AND pdp.`product_id` = p.`id` - WHERE dc.`department_id` = ? - AND dc.`deleted_at` IS NULL - AND p.`deleted_at` IS NULL + WHERE ' . implode(' AND ', $where) . ' ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC' ); if ($statement === false) { @@ -986,6 +1028,10 @@ class limited_backoffice_service private function loadManagedEmployee(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 lbe.*, @@ -995,7 +1041,7 @@ class limited_backoffice_service u.`phone_country_code`, u.`phone`, u.`group_id`, - u.`deleted_at` AS `user_deleted_at` + ' . $userDeletedAtSelect . ' FROM `limited_backoffice_employees` lbe INNER JOIN `users` u ON u.`id` = lbe.`user_id` WHERE lbe.`user_id` = ? diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index b002643b..87a780e2 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -63,11 +63,37 @@ function limited_backoffice_cleanup_created_employee(int $employeeId): void } } +function limited_backoffice_without_users_deleted_at(callable $callback): void +{ + $db = api_test_runtime()->db(); + $column = $db->query("SHOW COLUMNS FROM `users` LIKE 'deleted_at'"); + if ($column === false) { + throw new RuntimeException('Unable to inspect users.deleted_at test column.'); + } + + $hadColumn = (int)$column->num_rows > 0; + if ($hadColumn) { + $db->query('ALTER TABLE `users` DROP COLUMN `deleted_at`'); + } + + try { + $callback(); + } finally { + if ($hadColumn) { + $db->query('ALTER TABLE `users` ADD COLUMN `deleted_at` DATETIME NULL'); + } + } +} + 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'); + $productDeletedAtColumn = api_test_runtime()->db()->query("SHOW COLUMNS FROM `products` LIKE 'deleted_at'"); + expect($productDeletedAtColumn)->not->toBeFalse(); + expect((int)$productDeletedAtColumn->num_rows)->toBe(0); + $department = api_fixtures()->createDepartment(['name' => 'Limited Prices Own']); $otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Prices Other']); $category = api_fixtures()->createCategory(['name' => 'Limited Washes']); @@ -238,12 +264,17 @@ it('rejects invalid price batches and leaves existing prices unchanged', functio }); 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'); 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'); + $usersDeletedAtColumn = api_test_runtime()->db()->query("SHOW COLUMNS FROM `users` LIKE 'deleted_at'"); + expect($usersDeletedAtColumn)->not->toBeFalse(); + expect((int)$usersDeletedAtColumn->num_rows)->toBe(0); + $department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']); $session = limited_backoffice_manager_session([(int)$department['id']]); @@ -313,12 +344,16 @@ 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`, `deleted_at` FROM `users` WHERE `id` = ' . $employeeId); + $userRow = api_test_runtime()->queryOne('SELECT `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId); expect($userRow)->not->toBeNull(); expect(array_key_exists('password', $userRow ?? []))->toBeTrue(); expect($userRow['password'])->toBeNull(); expect((int)($userRow['group_id'] ?? -1))->toBe(0); - expect($userRow['deleted_at'] ?? null)->not->toBeNull(); + $employeeRow = api_test_runtime()->queryOne( + 'SELECT `deactivated_at` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1' + ); + expect($employeeRow['deactivated_at'] ?? null)->not->toBeNull(); + }); }); it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void { diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 33de7f55..6432a005 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -396,10 +396,8 @@ CREATE TABLE IF NOT EXISTS `products` ( `max_quantity_per_order` INT NULL, `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `deleted_at` DATETIME NULL, PRIMARY KEY (`id`), - KEY `idx_products_category` (`category`), - KEY `idx_products_deleted_at` (`deleted_at`) + KEY `idx_products_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'department_categories' => <<<'SQL'