Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce43c4e064 | ||
|
|
579ddcf510 | ||
|
|
0b342a7780 | ||
|
|
57bcbaf72a | ||
|
|
d9fbba3130 | ||
|
|
e4465d9d91 | ||
|
|
734cd13c87 | ||
|
|
d0f94ac549 | ||
|
|
1d25cbe21c | ||
|
|
53d0636193 | ||
|
|
04bb26f1b0 | ||
|
|
df0d4783d0 | ||
|
|
39c06ceab6 | ||
|
|
7dd428d18e | ||
|
|
0103a40156 | ||
|
|
e208b1b2a4 | ||
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
ed2736e528 | ||
|
|
c7f5c73a9e |
@@ -12445,6 +12445,29 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/roles/limited-backoffice-permission-templates:
|
||||
get:
|
||||
tags:
|
||||
- Roles
|
||||
summary: List limited backoffice permission templates
|
||||
operationId: listLimitedBackofficeRolePermissionTemplates
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key: {type: string}
|
||||
label: {type: string}
|
||||
description: {type: string}
|
||||
permissions:
|
||||
type: array
|
||||
items: {type: string}
|
||||
|
||||
/roles/permissions:
|
||||
post:
|
||||
tags:
|
||||
|
||||
@@ -14,24 +14,43 @@ 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<int, string>
|
||||
*/
|
||||
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
|
||||
'admin',
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
|
||||
];
|
||||
|
||||
/**
|
||||
* Permissions that are always granted to managed employees when present in a role preset,
|
||||
* regardless of whether the creating manager holds those permissions themselves.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ROLE_UNCONDITIONAL_PERMISSIONS = [
|
||||
'list_departments',
|
||||
'list_department_daily_reports',
|
||||
'list_notifications',
|
||||
'list_own_notifications',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{label:string,description:string,permissions:array<int,string>}>
|
||||
*/
|
||||
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 +58,58 @@ 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_departments',
|
||||
'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',
|
||||
'list_department_daily_reports',
|
||||
'list_notifications',
|
||||
'list_own_notifications',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
],
|
||||
],
|
||||
'booking_coordinator' => [
|
||||
@@ -59,16 +118,23 @@ class limited_backoffice_service
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_departments',
|
||||
'list_orders',
|
||||
'list_bookings',
|
||||
'list_own_bookings',
|
||||
'edit_bookings',
|
||||
'add_booking',
|
||||
'add_bookings',
|
||||
'complete_bookings',
|
||||
'resend_booking_confirmations',
|
||||
'department_timebookings_entries_get',
|
||||
'department_timebookings_entries_post',
|
||||
'department_timebookings_entries_put',
|
||||
'list_department_daily_reports',
|
||||
'list_notifications',
|
||||
'list_own_notifications',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
],
|
||||
],
|
||||
'operations_lead' => [
|
||||
@@ -77,21 +143,53 @@ class limited_backoffice_service
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_departments',
|
||||
'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',
|
||||
'list_department_daily_reports',
|
||||
'list_notifications',
|
||||
'list_own_notifications',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
],
|
||||
@@ -102,21 +200,53 @@ class limited_backoffice_service
|
||||
'permissions' => [
|
||||
'user',
|
||||
'permissions_list_own',
|
||||
'list_departments',
|
||||
'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',
|
||||
'list_department_daily_reports',
|
||||
'list_notifications',
|
||||
'list_own_notifications',
|
||||
'statistics_orders_new',
|
||||
'statistics_bookings_new',
|
||||
self::PERMISSION_ACCESS,
|
||||
@@ -142,6 +272,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 +288,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 +308,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 +428,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',
|
||||
@@ -210,6 +452,22 @@ class limited_backoffice_service
|
||||
'group' => 'time_bookings',
|
||||
'capability' => 'edit_time_booking_entries',
|
||||
],
|
||||
'list_departments' => [
|
||||
'group' => 'departments',
|
||||
'capability' => 'view_departments',
|
||||
],
|
||||
'list_department_daily_reports' => [
|
||||
'group' => 'departments',
|
||||
'capability' => 'view_daily_reports',
|
||||
],
|
||||
'list_notifications' => [
|
||||
'group' => 'notifications',
|
||||
'capability' => 'view_notifications',
|
||||
],
|
||||
'list_own_notifications' => [
|
||||
'group' => 'notifications',
|
||||
'capability' => 'view_notifications',
|
||||
],
|
||||
'statistics_orders_new' => [
|
||||
'group' => 'reports',
|
||||
'capability' => 'view_order_statistics',
|
||||
@@ -237,9 +495,16 @@ class limited_backoffice_service
|
||||
*/
|
||||
private const ROLE_PERMISSION_GROUP_ORDER = [
|
||||
'account',
|
||||
'departments',
|
||||
'orders',
|
||||
'products',
|
||||
'customers',
|
||||
'vehicles',
|
||||
'attachments',
|
||||
'scanner',
|
||||
'bookings',
|
||||
'time_bookings',
|
||||
'notifications',
|
||||
'reports',
|
||||
'limited_backoffice',
|
||||
];
|
||||
@@ -286,6 +551,23 @@ class limited_backoffice_service
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,description:string,permissions:array<int,string>}>
|
||||
*/
|
||||
public function rolePermissionTemplates(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach (self::ROLE_PRESETS as $key => $preset) {
|
||||
$templates[] = [
|
||||
'key' => $key,
|
||||
'label' => $preset['label'],
|
||||
'description' => $preset['description'],
|
||||
'permissions' => array_values($preset['permissions']),
|
||||
];
|
||||
}
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
@@ -333,6 +615,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 +865,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 +890,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 +924,72 @@ class limited_backoffice_service
|
||||
return $this->formatEmployee($employee, $departmentIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
@@ -1295,6 +1644,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<int, string>
|
||||
*/
|
||||
@@ -1308,6 +1670,11 @@ class limited_backoffice_service
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($permission, self::ROLE_UNCONDITIONAL_PERMISSIONS, true)) {
|
||||
$permissions[] = $permission;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($manager->hasPermission($permission)) {
|
||||
$permissions[] = $permission;
|
||||
}
|
||||
@@ -1364,29 +1731,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<string, mixed>|null
|
||||
*/
|
||||
@@ -1424,6 +1768,42 @@ class limited_backoffice_service
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|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<string, mixed> $row
|
||||
*/
|
||||
@@ -1518,6 +1898,25 @@ class limited_backoffice_service
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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) {
|
||||
|
||||
@@ -1246,19 +1246,20 @@ class xlvask_automation_service
|
||||
{
|
||||
global $db;
|
||||
(new xlvask_usage_logs_o())->structure();
|
||||
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
|
||||
$where = [
|
||||
'FinishStatus = 1',
|
||||
'(ignored_at IS NULL OR ignored_at = "")',
|
||||
];
|
||||
|
||||
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
} else {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
}
|
||||
|
||||
if ($dateTo !== null && strtotime($dateTo) !== false) {
|
||||
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
}
|
||||
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
@@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper
|
||||
|
||||
private function unsetNullifiableProperties(): void
|
||||
{
|
||||
$nullable_review_metadata = [
|
||||
'ignored_at',
|
||||
'ignored_reason',
|
||||
];
|
||||
// Unset properties that are null or empty strings
|
||||
$properties = [
|
||||
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location',
|
||||
@@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper
|
||||
if ($this->isEmptyOrDefault($this->{$property})) {
|
||||
$tmp_value = $this->{$property};
|
||||
if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) {
|
||||
$this->{$property} = ''; // Set to null if it matches the default string
|
||||
$this->{$property} = (
|
||||
$tmp_value === $this->default_string_nullable
|
||||
&& in_array($property, $nullable_review_metadata, true)
|
||||
) ? null : '';
|
||||
} elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) {
|
||||
if ($tmp_value === $this->default_int_nullable) {
|
||||
$this->{$property} = null; // Set to null if it matches the default int nullable
|
||||
|
||||
@@ -82,7 +82,7 @@ class xlvask_usage_logs_o extends db
|
||||
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
|
||||
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
|
||||
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'string', false);
|
||||
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false);
|
||||
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
|
||||
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
|
||||
}
|
||||
@@ -195,18 +195,20 @@ class xlvask_usage_logs_o extends db
|
||||
|
||||
/**
|
||||
* Import the usage logs from XL Vask
|
||||
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
|
||||
* @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'.
|
||||
* @param string|null $dateTo Optional inclusive import end date.
|
||||
* @throws Exception If the objects were not successfully added.
|
||||
* @returns void
|
||||
*/
|
||||
public function importUsageLogs(string $dateTimeModifier = '-7 days'): void
|
||||
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
|
||||
{
|
||||
if (!empty($this->id)) {
|
||||
throw new Exception('To prevent issues, having a selected object is not allowed.');
|
||||
}
|
||||
$usage_logs = $this->getUsageLogsFromXLVask(
|
||||
date('Y-m-d\TH:i:s.000', strtotime($dateTimeModifier)) // Example: '2025-05-01T00:00:00.000'
|
||||
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
|
||||
);
|
||||
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
|
||||
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
|
||||
$known_usage_logIds = array_map(function ($log) {
|
||||
return $log['WashId'];
|
||||
@@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db
|
||||
unset($new_usage_logs);
|
||||
}
|
||||
|
||||
private static function formatImportDateFrom(?string $dateFrom): string
|
||||
{
|
||||
$dateFrom = trim((string)($dateFrom ?? ''));
|
||||
$timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom);
|
||||
|
||||
if ($timestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateFrom');
|
||||
}
|
||||
|
||||
return date('Y-m-d\TH:i:s.000', $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param xlvask_usage_log[] $usageLogs
|
||||
* @return xlvask_usage_log[]
|
||||
* @throws Exception
|
||||
*/
|
||||
private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array
|
||||
{
|
||||
$dateTo = trim((string)($dateTo ?? ''));
|
||||
if ($dateTo === '') {
|
||||
return $usageLogs;
|
||||
}
|
||||
|
||||
$dateToTimestamp = strtotime($dateTo);
|
||||
if ($dateToTimestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateTo');
|
||||
}
|
||||
|
||||
$inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp));
|
||||
if ($inclusiveEndTimestamp === false) {
|
||||
throw new Exception('Invalid XL Vask usage import dateTo');
|
||||
}
|
||||
|
||||
return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) {
|
||||
$startTimestamp = strtotime((string)$log->StartTime);
|
||||
return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function retrieves the usage logs from XL Vask
|
||||
* @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
|
||||
|
||||
@@ -12422,6 +12422,29 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/roles/limited-backoffice-permission-templates:
|
||||
get:
|
||||
tags:
|
||||
- Roles
|
||||
summary: List limited backoffice permission templates
|
||||
operationId: listLimitedBackofficeRolePermissionTemplates
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key: {type: string}
|
||||
label: {type: string}
|
||||
description: {type: string}
|
||||
permissions:
|
||||
type: array
|
||||
items: {type: string}
|
||||
|
||||
/roles/permissions:
|
||||
post:
|
||||
tags:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -255,11 +255,13 @@ class moduleXLVaskRoute
|
||||
$this->get('/modules/xlvask/tasks/import-usage', function () {
|
||||
global $response;
|
||||
self::requirePermission('modules_xlvask_import_usage');
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
|
||||
// Create the xlvask_usage_logs_o object
|
||||
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
|
||||
// Import usage logs
|
||||
$xlvask_usage_logs_o->importUsageLogs();
|
||||
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
|
||||
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
|
||||
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
|
||||
// Response
|
||||
$response->success(
|
||||
'Usage logs imported',
|
||||
|
||||
@@ -75,13 +75,12 @@ class orderItemsRoute
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 404);
|
||||
}
|
||||
// Check if the user has access to the department
|
||||
self::requireDepartmentAccess((string)(int)$order->department_id->value());
|
||||
$product = (new products_o())->getProductById((int)$data['product_id']);
|
||||
if (!$product->exists()) {
|
||||
$response->error('Product not found', 404);
|
||||
}
|
||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
$customerRuleViolation = (new customer_product_rule_service())
|
||||
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
|
||||
if ($customerRuleViolation !== null) {
|
||||
@@ -95,6 +94,9 @@ class orderItemsRoute
|
||||
);
|
||||
$response->error($customerRuleViolation['message'], 400);
|
||||
}
|
||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
}
|
||||
|
||||
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
|
||||
$order_items = (new order_items_o());
|
||||
@@ -173,18 +175,38 @@ class orderItemsRoute
|
||||
|
||||
$this->delete('/order/items', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
global $response, $db;
|
||||
$this->requirePermission('delete_order_items');
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
// Check if the request was successful
|
||||
if ($user) {
|
||||
// Get the query data
|
||||
$data = $_GET;
|
||||
// Check if the required fields are set
|
||||
if (!isset($data['id'])) {
|
||||
// Get the order item id from the query string or request body
|
||||
$itemIdRaw = $this->fromRequest('id');
|
||||
if ($itemIdRaw === null || $itemIdRaw === '') {
|
||||
$response->error('Order Item ID is required', 400);
|
||||
}
|
||||
$data = ['id' => $itemIdRaw];
|
||||
// Look up the order item to check department access
|
||||
$itemId = (int)$data['id'];
|
||||
$stmt = $db->prepare('SELECT oi.order_id FROM order_items oi WHERE oi.id = ? LIMIT 1');
|
||||
if ($stmt === false) {
|
||||
(new logs_o())->add('order_items', 'global', 1, 0, 'DELETE_ORDER_ITEMS', 'Database error while preparing department access check query');
|
||||
$response->error('Database error while checking department access', 500);
|
||||
}
|
||||
$stmt->bind_param('i', $itemId);
|
||||
$stmt->execute();
|
||||
$orderItemRow = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if ($orderItemRow !== null) {
|
||||
$orderForAccess = (new orders_o())->getOrderById((int)$orderItemRow['order_id']);
|
||||
if (!$orderForAccess->exists()) {
|
||||
$response->error('Order not found', 404);
|
||||
}
|
||||
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
|
||||
} else {
|
||||
$response->error('Order item not found', 404);
|
||||
}
|
||||
// Delete the order item
|
||||
(new order_items_o())->removeOrderItem((int)$data['id']);
|
||||
// Return the list of departments
|
||||
@@ -261,6 +283,8 @@ class orderItemsRoute
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 404);
|
||||
}
|
||||
// Check if the user has access to the department
|
||||
self::requireDepartmentAccess((string)(int)$order->department_id->value());
|
||||
|
||||
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
|
||||
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
|
||||
|
||||
@@ -172,6 +172,8 @@ class ordersRoute
|
||||
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
|
||||
$response->error('Department not found', 400);
|
||||
}
|
||||
// Check if the user has access to the department
|
||||
self::requireDepartmentAccess((string)(int)$data['department_id']);
|
||||
// Make sure the customer number set is valid
|
||||
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
|
||||
if (!$targetUser->exists()) {
|
||||
@@ -472,6 +474,8 @@ class ordersRoute
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 400);
|
||||
}
|
||||
// Check if the user has access to the department
|
||||
self::requireDepartmentAccess((string)(int)$order->department_id->value());
|
||||
// Get the base64 file
|
||||
$base64_file = (string)$this->getParameter('base64_file');
|
||||
$attachment_store = new attachment_store();
|
||||
@@ -530,6 +534,8 @@ class ordersRoute
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 400);
|
||||
}
|
||||
// Check if the user has access to the department
|
||||
self::requireDepartmentAccess((string)(int)$order->department_id->value());
|
||||
// Delete the attachment
|
||||
$order->removeAttachment((int)$attachment_id);
|
||||
// Log the incident
|
||||
@@ -568,6 +574,8 @@ class ordersRoute
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 400);
|
||||
}
|
||||
// Check if the user has access to the department
|
||||
self::requireDepartmentAccess((string)(int)$order->department_id->value());
|
||||
// Mark the order as completed
|
||||
$order->markAsCompleted((string)$user->display_name->value());
|
||||
// Log the incident
|
||||
@@ -1154,7 +1162,8 @@ class ordersRoute
|
||||
}
|
||||
// Admin/department path (requires edit_order)
|
||||
self::requirePermission($permission_other);
|
||||
/** Departmental access */
|
||||
/** Departmental access — user must have access to the order's current department */
|
||||
self::requireDepartmentAccess((string)(int)$order->department_id->value());
|
||||
$originalCustomerNumber = (int)$order->customer_id->value();
|
||||
$newCustomerNumber = $originalCustomerNumber;
|
||||
$shouldAutoReassignInvoiceCollection = false;
|
||||
@@ -1219,6 +1228,8 @@ class ordersRoute
|
||||
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
|
||||
$response->error('Department not found', 400);
|
||||
}
|
||||
// Check if the user has access to the target department
|
||||
self::requireDepartmentAccess((string)(int)$data['department_id']);
|
||||
$order->department_id->set((int)$data['department_id']);
|
||||
}
|
||||
// If the booking ID is set, validate it
|
||||
@@ -1362,11 +1373,7 @@ class ordersRoute
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasDepartmentAccess((string)$departmentId);
|
||||
return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -22,21 +22,23 @@ class productsRoute
|
||||
*/
|
||||
private function getCustomerIfProvided(): ?users_o
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['customer_id'])) {
|
||||
$customerId = (int)self::getParameter('customer_id');
|
||||
try {
|
||||
$customerObject = (new users_o())->getUserByCustomerNumber((int)$customerId);
|
||||
if ($customerObject->exists()) {
|
||||
return $customerObject;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||
// Return null
|
||||
return null;
|
||||
}
|
||||
$customerId = $this->getOptionalPositiveIntParameter('customer_id');
|
||||
if ($customerId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$customerObject = (new users_o())->getUserByCustomerNumber($customerId);
|
||||
if ($customerObject->exists()) {
|
||||
return $customerObject;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage());
|
||||
// Return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,12 +47,49 @@ class productsRoute
|
||||
* @return int|null
|
||||
*/
|
||||
private function getDepartmentIdIfProvided(): ?int
|
||||
{
|
||||
return $this->getOptionalPositiveIntParameter('department_id');
|
||||
}
|
||||
|
||||
private function getOptionalPositiveIntParameter(string $parameter): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['department_id'])) {
|
||||
return (int)self::getParameter('department_id');
|
||||
if (!self::isParametersSet([$parameter])) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
$value = self::getParameter($parameter);
|
||||
if ($this->isNullLikeOptionalParameter($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = null;
|
||||
if (is_int($value)) {
|
||||
$parsed = $value;
|
||||
} elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
|
||||
$parsed = (int)trim($value);
|
||||
} else {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
if ($parsed === null || $parsed <= 0) {
|
||||
$response->error('Invalid ' . $parameter, 400);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isNullLikeOptionalParameter(mixed $value): bool
|
||||
{
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true);
|
||||
}
|
||||
|
||||
private function assertCanUseDepartmentPricing(mixed $user, ?int $departmentId): void
|
||||
@@ -72,11 +111,7 @@ class productsRoute
|
||||
*/
|
||||
private function getCategoryIfProvided(): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['category'])) {
|
||||
return (int)self::getParameter('category');
|
||||
}
|
||||
return null;
|
||||
return $this->getOptionalPositiveIntParameter('category');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,11 +120,7 @@ class productsRoute
|
||||
*/
|
||||
private function getProductIdIfProvided(): ?int
|
||||
{
|
||||
global $response;
|
||||
if (self::isParametersSet(['id'])) {
|
||||
return (int)self::getParameter('id');
|
||||
}
|
||||
return null;
|
||||
return $this->getOptionalPositiveIntParameter('id');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,13 +241,14 @@ class productsRoute
|
||||
// Check if the request was successful
|
||||
if ($user || $isProductDetailsRestricted) {
|
||||
// Define the variables
|
||||
$customer = self::getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
||||
$departmentId = self::getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
$category = self::getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||
$productId = self::getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||
$customer = $this->getCustomerIfProvided(); // This is only used if the customer_id parameter is provided
|
||||
$departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided
|
||||
$category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category)
|
||||
$productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product)
|
||||
$useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true';
|
||||
// Check if the "final_price" parameter is set, and true.
|
||||
if (self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true') {
|
||||
if ($useFinalPrice) {
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
// Determine the products to return
|
||||
if ($category) {
|
||||
// Get products in the category
|
||||
@@ -274,17 +306,17 @@ class productsRoute
|
||||
);
|
||||
}
|
||||
// Check if the category is set in the request
|
||||
$data = $_GET ?? [];
|
||||
// Check if the category is set
|
||||
if (isset($data['category'])) {
|
||||
if ($category !== null) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $data['category']);
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category);
|
||||
// Return the list of products
|
||||
$products = (new products_o())->listObjectsByCategory($data['category']);
|
||||
$products = (new products_o())->listObjectsByCategory($category);
|
||||
// Check if the department_id is set
|
||||
if (isset($data['department_id'])) {
|
||||
if ($departmentId !== null) {
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
// Apply the departments unique pricing
|
||||
$products = (new products_o())->applyDepartmentPricing((array)$products, (int)$data['department_id']);
|
||||
$products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId);
|
||||
}
|
||||
$response->success(
|
||||
array_map(function ($product) use ($isProductDetailsRestricted) {
|
||||
@@ -295,9 +327,10 @@ class productsRoute
|
||||
// Log the incident
|
||||
(new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products');
|
||||
// Check if the department_id is set
|
||||
if (isset($data['department_id'])) {
|
||||
if ($departmentId !== null) {
|
||||
$this->assertCanUseDepartmentPricing($user, $departmentId);
|
||||
// Get all product ids contained in a category attached to the department
|
||||
$departmentSpecificProducts = (new departments_o())->select((int)$data['department_id'])->getAllProductInDepartmentCategories();
|
||||
$departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories();
|
||||
// Get the product ids as an array
|
||||
$departmentSpecificProductIds = array_map(function ($product) {
|
||||
return $product->id;
|
||||
@@ -312,7 +345,7 @@ class productsRoute
|
||||
(new products_o())->forceRestrictFilters([
|
||||
'id' => $departmentSpecificProductIds,
|
||||
])
|
||||
), (int)$data['department_id'])
|
||||
), $departmentId)
|
||||
);
|
||||
}
|
||||
// Return the list of products
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\limited_backoffice_service;
|
||||
use objects\groups_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
@@ -106,6 +107,25 @@ class rolesRoute
|
||||
]
|
||||
);
|
||||
|
||||
self::get('/roles/limited-backoffice-permission-templates', function () {
|
||||
global $response;
|
||||
self::requirePermission('superuser');
|
||||
self::requirePermission('add_role_permission');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
|
||||
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
|
||||
} else {
|
||||
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'superuser' => 'Access the superuser interface',
|
||||
'add_role_permission' => 'Add a permission to a role'
|
||||
]
|
||||
);
|
||||
|
||||
self::post('/roles/permissions', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -182,4 +202,4 @@ class rolesRoute
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ class usersRoute
|
||||
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
|
||||
$filters = $this->fromQuery('filters');
|
||||
|
||||
if (!$enabled || $filters === null || $filters === '') {
|
||||
if ($filters === null || $filters === '') {
|
||||
return [
|
||||
'enabled' => false,
|
||||
'filters' => null,
|
||||
@@ -257,16 +257,31 @@ class usersRoute
|
||||
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
|
||||
|
||||
if (!$isEmployeeFilter) {
|
||||
// When include mode is on but the filter is not a customer_number:0 query,
|
||||
// pass the original filter through as forced filters so they are not discarded.
|
||||
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
|
||||
// back to reading the filters from the request, which is equivalent.
|
||||
return [
|
||||
'enabled' => false,
|
||||
'filters' => $filters,
|
||||
'filters' => $enabled ? $filters : null,
|
||||
'additional_where' => null,
|
||||
];
|
||||
}
|
||||
|
||||
unset($filterArray['customer_number']);
|
||||
// $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
|
||||
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
|
||||
|
||||
if (!$enabled) {
|
||||
// Exclude active limited backoffice employees when the include flag is not set.
|
||||
return [
|
||||
'enabled' => false,
|
||||
'filters' => null,
|
||||
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
|
||||
];
|
||||
}
|
||||
|
||||
unset($filterArray['customer_number']);
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -150,7 +150,7 @@ it('uses a product fixed price instead of the best discount when adding an order
|
||||
'cashier_id' => $cashier['id'],
|
||||
'reference' => 'FIXED-PRICE',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products']);
|
||||
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products', 'department_access_' . $department['id']]);
|
||||
|
||||
$productResponse = api_client()->get(
|
||||
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
|
||||
@@ -281,7 +281,7 @@ it('does not allow clearing notes for order items whose product requires notes',
|
||||
'quantity' => 1,
|
||||
'notes' => 'Initial note',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']);
|
||||
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
|
||||
|
||||
api_client()
|
||||
->put('/order/items', [
|
||||
|
||||
@@ -73,7 +73,7 @@ it('creates orders through the orders endpoint', function (): void {
|
||||
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Order Create Department']);
|
||||
$session = api_fixtures()->createUserSession(['add_order']);
|
||||
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
|
||||
|
||||
$response = api_client()->post('/orders', [
|
||||
'customer_id' => $customer['customer_number'],
|
||||
@@ -128,7 +128,7 @@ it('defaults order PO only from a matching active booking', function (): void {
|
||||
'po' => 'DELETED-BOOKING-PO',
|
||||
'deleted_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [
|
||||
$session = api_fixtures()->createUserSession(['add_order', 'edit_order', 'department_access_' . $department['id']], [
|
||||
'customer_number' => $customer['customer_number'],
|
||||
]);
|
||||
|
||||
@@ -149,7 +149,7 @@ it('defaults order PO only from a matching active booking', function (): void {
|
||||
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
|
||||
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
|
||||
|
||||
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [
|
||||
$unauthorizedSession = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']], [
|
||||
'customer_number' => $otherCustomer['customer_number'],
|
||||
]);
|
||||
$unauthorizedResponse = api_client()->post('/orders', [
|
||||
@@ -233,7 +233,7 @@ it('rejects invalid order creation requests', function (): void {
|
||||
|
||||
$customer = api_fixtures()->createUser();
|
||||
$department = api_fixtures()->createDepartment();
|
||||
$session = api_fixtures()->createUserSession(['add_order']);
|
||||
$session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
|
||||
|
||||
api_client()->post('/orders', [
|
||||
'customer_id' => $customer['customer_number'],
|
||||
@@ -262,7 +262,7 @@ it('updates orders through the primary and legacy endpoints', function (): void
|
||||
'notes' => 'Before update',
|
||||
'reg_1' => 'BEFORE1',
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['edit_order']);
|
||||
$session = api_fixtures()->createUserSession(['edit_order', 'department_access_' . $department['id']]);
|
||||
|
||||
api_client()->put('/orders', [
|
||||
'id' => $order['id'],
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('treats null-like optional product params as omitted for product detail requests', function (): void {
|
||||
api_test_covers('GET /products', 'optional-params');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Null Query Product',
|
||||
'price' => 400,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/products?id=' . (int)$product['id']
|
||||
. '&department_id=null&customer_id=null&category_id=null&final_price=false',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())
|
||||
->toBeArray()
|
||||
->toHaveKey('id', (int)$product['id']);
|
||||
expect($response->body)->not->toContain('department_access_0');
|
||||
});
|
||||
|
||||
it('still requires department access when final product pricing uses a real department', function (): void {
|
||||
api_test_covers('GET /products', 'permissions');
|
||||
|
||||
$department = api_fixtures()->createDepartment(['name' => 'Product Pricing Department']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Department Priced Product',
|
||||
'price' => 500,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['list_products']);
|
||||
|
||||
api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id']
|
||||
. '&department_id=' . (int)$department['id'],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['department_access_' . (int)$department['id']]);
|
||||
});
|
||||
|
||||
it('rejects invalid department ids without requesting department access zero', function (): void {
|
||||
api_test_covers('GET /products', 'validation');
|
||||
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Invalid Department Product',
|
||||
'price' => 600,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['list_products']);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/products?final_price=true&id=' . (int)$product['id'] . '&department_id=0',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid department_id');
|
||||
|
||||
expect($response->body)->not->toContain('department_access_0');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('lists limited backoffice permission templates for superuser role maintenance', function (): void {
|
||||
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'happy');
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'superuser',
|
||||
'add_role_permission',
|
||||
]);
|
||||
|
||||
$response = api_client()->get('/roles/limited-backoffice-permission-templates', $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$templates = $response->data();
|
||||
expect(array_column($templates, 'key'))->toBe([
|
||||
'viewer',
|
||||
'cashier',
|
||||
'booking_coordinator',
|
||||
'operations_lead',
|
||||
'department_admin',
|
||||
]);
|
||||
|
||||
$templatesByKey = array_column($templates, null, 'key');
|
||||
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_department_daily_reports');
|
||||
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_notifications');
|
||||
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('statistics_orders_new');
|
||||
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_access');
|
||||
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_prices_manage');
|
||||
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_employees_manage');
|
||||
});
|
||||
|
||||
it('requires superuser and role permission edit access for limited backoffice permission templates', function (): void {
|
||||
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'auth');
|
||||
|
||||
api_client()
|
||||
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['add_role_permission'])['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['superuser']);
|
||||
|
||||
api_client()
|
||||
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['superuser'])['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['add_role_permission']);
|
||||
});
|
||||
@@ -3,6 +3,20 @@
|
||||
use helpers\xlvask_usage_log;
|
||||
use objects\xlvask_usage_logs_o;
|
||||
|
||||
it('serializes empty ignore metadata as SQL null values for new usage logs', function (): void {
|
||||
$log = new xlvask_usage_log();
|
||||
$data = $log->toArray();
|
||||
|
||||
expect($data)
|
||||
->toHaveKey('ignored_at')
|
||||
->toHaveKey('ignored_by')
|
||||
->toHaveKey('ignored_reason')
|
||||
->and($data['ignored_at'])->toBeNull()
|
||||
->and($data['ignored_by'])->toBeNull()
|
||||
->and($data['ignored_reason'])->toBeNull()
|
||||
->and($data['Updated'])->toBe('');
|
||||
});
|
||||
|
||||
it('accepts persisted ignore metadata from xlvask usage log rows', function (): void {
|
||||
$log = new xlvask_usage_log();
|
||||
|
||||
@@ -57,3 +71,21 @@ it('calculates XL Vask amount summaries without hydrating order item previews',
|
||||
'primary_product_name' => 'Stor bil',
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats date-only XL Vask usage import start dates for the upstream API', function (): void {
|
||||
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'formatImportDateFrom');
|
||||
|
||||
expect($method->invoke(null, '2026-03-01'))->toBe('2026-03-01T00:00:00.000');
|
||||
});
|
||||
|
||||
it('filters fetched XL Vask usage logs inclusively to the requested import end date', function (): void {
|
||||
$keep = new xlvask_usage_log(['StartTime' => '2026-03-31T23:59:59.000']);
|
||||
$drop = new xlvask_usage_log(['StartTime' => '2026-04-01T00:00:00.000']);
|
||||
$method = new ReflectionMethod(xlvask_usage_logs_o::class, 'filterUsageLogsUntil');
|
||||
|
||||
$result = $method->invoke(null, [$keep, $drop], '2026-03-31');
|
||||
|
||||
expect($result)
|
||||
->toHaveCount(1)
|
||||
->and($result[0])->toBe($keep);
|
||||
});
|
||||
|
||||
@@ -43,3 +43,22 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
|
||||
->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']")
|
||||
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
|
||||
});
|
||||
|
||||
it('scopes manual XL Vask usage import and automation to optional period dates', function (): void {
|
||||
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
|
||||
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
|
||||
|
||||
expect($route)
|
||||
->not->toBeFalse()
|
||||
->and($automation)->not->toBeFalse();
|
||||
|
||||
$route = (string)$route;
|
||||
$automation = (string)$automation;
|
||||
|
||||
expect($route)
|
||||
->toContain("getParameter('dateFrom')")
|
||||
->toContain("getParameter('dateTo')")
|
||||
->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)')
|
||||
->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
|
||||
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user