Add subuser permission templates service and related tests
This commit is contained in:
+439
@@ -1455,6 +1455,183 @@ paths:
|
||||
$ref: '#/components/schemas/BirdFlashCallHangupResponse'
|
||||
|
||||
# Subusers (public registration + setup)
|
||||
/superuser/users/{user_id}/subusers:
|
||||
get:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: List subusers for a superuser customer account
|
||||
description: Returns paginated driver access grants for the customer number resolved from the selected user.
|
||||
operationId: listSuperuserUserSubusers
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 1000 }
|
||||
- name: search
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
- name: include_non_enabled
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped subuser list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserManagementRow'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/summary:
|
||||
get:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Summarize subusers for a superuser customer account
|
||||
operationId: summarizeSuperuserUserSubusers
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped subuser summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SubuserManagementSummary'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/invite:
|
||||
post:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Invite a subuser for a superuser customer account
|
||||
operationId: inviteSuperuserUserSubuser
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, phone_country_code, phone]
|
||||
properties:
|
||||
name: { type: string, minLength: 3, maxLength: 255 }
|
||||
phone_country_code: { type: integer }
|
||||
phone: { type: integer }
|
||||
note: { type: string, nullable: true, maxLength: 65535 }
|
||||
enabled: { type: boolean, default: true }
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Driver invited or linked
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend:
|
||||
post:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Resend a user-scoped subuser invite
|
||||
operationId: resendSuperuserUserSubuserInvite
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: subuser_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
'200':
|
||||
description: Invite resent
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/grants/{grant_id}:
|
||||
patch:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Update a user-scoped subuser grant
|
||||
operationId: updateSuperuserUserSubuserGrant
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: grant_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SubuserGrantUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Grant updated
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/subusers:
|
||||
get:
|
||||
tags:
|
||||
@@ -2038,6 +2215,35 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/subusers/permission-templates:
|
||||
get:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: List simplified subuser permission templates
|
||||
description: Returns backend-owned driver access profiles and grouped capability metadata for subuser grants.
|
||||
operationId: listSubuserPermissionTemplates
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Permission templates fetched
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
templates:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionTemplate'
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionGroup'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
# Authentication Endpoints
|
||||
/auth/login:
|
||||
post:
|
||||
@@ -7438,6 +7644,139 @@ paths:
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/vehicles:
|
||||
get:
|
||||
tags: [Vehicles]
|
||||
summary: List vehicles for a selected superuser user
|
||||
operationId: listSuperuserUserVehicles
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- $ref: '#/components/parameters/PageParam'
|
||||
- $ref: '#/components/parameters/PerPageParam'
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped vehicles retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Vehicle'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
post:
|
||||
tags: [Vehicles]
|
||||
summary: Add a vehicle for a selected superuser user
|
||||
operationId: addSuperuserUserVehicle
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [reg, type]
|
||||
properties:
|
||||
reg: { type: string, minLength: 2, maxLength: 12 }
|
||||
type: { type: integer }
|
||||
wash_subscription: { type: boolean, default: false }
|
||||
reference: { type: string, nullable: true, maxLength: 255 }
|
||||
customer_id:
|
||||
type: integer
|
||||
description: Optional guard value; must match the selected user's customer number.
|
||||
responses:
|
||||
'200':
|
||||
description: Vehicle created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Vehicle'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
put:
|
||||
tags: [Vehicles]
|
||||
summary: Edit a vehicle for a selected superuser user
|
||||
operationId: editSuperuserUserVehicle
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: { type: integer, minimum: 1 }
|
||||
reg: { type: string, minLength: 2, maxLength: 12 }
|
||||
type: { type: integer }
|
||||
wash_subscription: { type: boolean }
|
||||
reference: { type: string, nullable: true, maxLength: 255 }
|
||||
customer_id:
|
||||
type: integer
|
||||
description: Optional guard value; moving vehicles between customers is not allowed here.
|
||||
responses:
|
||||
'200':
|
||||
description: Vehicle updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Vehicle'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
delete:
|
||||
tags: [Vehicles]
|
||||
summary: Delete a vehicle for a selected superuser user
|
||||
operationId: deleteSuperuserUserVehicle
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: id
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
'200': { description: Vehicle deleted }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/superuser/users/{user_id}/vehicles/summary:
|
||||
get:
|
||||
tags: [Vehicles]
|
||||
summary: Summarize vehicles for a selected superuser user
|
||||
operationId: summarizeSuperuserUserVehicles
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped vehicle summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VehicleManagementSummary'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
# Vehicles Endpoints
|
||||
/vehicles:
|
||||
get:
|
||||
@@ -16532,6 +16871,55 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
|
||||
SubuserManagementRow:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
username: { type: string, nullable: true }
|
||||
name: { type: string, nullable: true }
|
||||
email: { type: string, format: email, nullable: true }
|
||||
phone_country_code: { type: integer, nullable: true }
|
||||
phone: { type: integer, nullable: true }
|
||||
created_at: { type: string, format: date-time, nullable: true }
|
||||
updated_at: { type: string, format: date-time, nullable: true }
|
||||
suspended_at: { type: string, format: date-time, nullable: true }
|
||||
two_factor_enabled: { type: boolean }
|
||||
setup_required: { type: boolean }
|
||||
invite_accepted: { type: boolean }
|
||||
can_resend_invite: { type: boolean }
|
||||
profile_editable_by_manager: { type: boolean }
|
||||
customer_number: { type: integer }
|
||||
customer_name: { type: string, nullable: true }
|
||||
grant_id: { type: integer, nullable: true }
|
||||
grant_enabled: { type: boolean }
|
||||
grant_note: { type: string, nullable: true }
|
||||
grant_permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin, custom]
|
||||
permission_groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionGroup'
|
||||
grant_created_at: { type: string, format: date-time, nullable: true }
|
||||
grant_updated_at: { type: string, format: date-time, nullable: true }
|
||||
access_state:
|
||||
type: string
|
||||
enum: [active, pending_setup, disabled, inactive]
|
||||
|
||||
SubuserManagementSummary:
|
||||
type: object
|
||||
properties:
|
||||
total: { type: integer }
|
||||
active: { type: integer }
|
||||
pending_setup: { type: integer }
|
||||
disabled: { type: integer }
|
||||
|
||||
SubuserGrantCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
@@ -16556,6 +16944,9 @@ components:
|
||||
description: Optional list of permission keys; defaults will be applied if omitted
|
||||
items:
|
||||
type: string
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
|
||||
SubuserGrantUpdateRequest:
|
||||
type: object
|
||||
@@ -16570,6 +16961,40 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
|
||||
SubuserPermissionGroup:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
SubuserPermissionTemplate:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
permissions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
permission_groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionGroup'
|
||||
|
||||
SubuserGrantSummary:
|
||||
type: object
|
||||
@@ -20188,6 +20613,20 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
VehicleManagementSummary:
|
||||
type: object
|
||||
required: [total, wash_subscription, self_service]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
wash_subscription:
|
||||
type: integer
|
||||
minimum: 0
|
||||
self_service:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
Notification:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
|
||||
class subuser_permission_templates_service
|
||||
{
|
||||
public const TEMPLATE_DEACTIVATED = 'deactivated';
|
||||
public const TEMPLATE_DRIVER = 'driver';
|
||||
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
|
||||
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
|
||||
public const TEMPLATE_CUSTOM = 'custom';
|
||||
|
||||
/**
|
||||
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
|
||||
*/
|
||||
private const TEMPLATES = [
|
||||
self::TEMPLATE_DEACTIVATED => [
|
||||
'label' => 'Deactivated',
|
||||
'description' => 'Keeps the driver linked to the customer without active access.',
|
||||
'enabled' => false,
|
||||
'permissions' => [],
|
||||
],
|
||||
self::TEMPLATE_DRIVER => [
|
||||
'label' => 'Driver',
|
||||
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_BOOKING_COORDINATOR => [
|
||||
'label' => 'Booking coordinator',
|
||||
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_FLEET_ADMIN => [
|
||||
'label' => 'Fleet admin',
|
||||
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'VEHICLES_EDIT',
|
||||
'VEHICLES_DELETE',
|
||||
'VEHICLES_ADD',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_EDIT',
|
||||
'SELFSERVE_DELETE',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
'ORDERS_EDIT',
|
||||
'SUBUSERS_LIST',
|
||||
'SUBUSERS_EDIT',
|
||||
'SUBUSERS_DELETE',
|
||||
'SUBUSERS_ADD',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{group:string,capability:string}>
|
||||
*/
|
||||
private const PERMISSION_CAPABILITIES = [
|
||||
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
|
||||
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
|
||||
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
|
||||
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
|
||||
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
|
||||
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
|
||||
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
|
||||
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
|
||||
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
|
||||
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
|
||||
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
|
||||
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
|
||||
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
|
||||
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
|
||||
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
|
||||
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
|
||||
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
|
||||
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const GROUP_ORDER = [
|
||||
'vehicles',
|
||||
'selfserve',
|
||||
'bookings',
|
||||
'orders',
|
||||
'driver_management',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function accessModel(): array
|
||||
{
|
||||
return [
|
||||
'templates' => $this->templates(),
|
||||
'groups' => $this->groups(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function templates(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
$templates[] = [
|
||||
'key' => $key,
|
||||
'label' => $template['label'],
|
||||
'description' => $template['description'],
|
||||
'enabled' => $template['enabled'],
|
||||
'permissions' => array_values($template['permissions']),
|
||||
'permission_groups' => $this->permissionGroups($template['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function groups(): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
$capabilities = [];
|
||||
foreach (self::PERMISSION_CAPABILITIES as $capability) {
|
||||
if ($capability['group'] === $group) {
|
||||
$capabilities[] = $capability['capability'];
|
||||
}
|
||||
}
|
||||
$groups[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($capabilities)),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}
|
||||
*/
|
||||
public function expandTemplate(string $templateKey): array
|
||||
{
|
||||
$key = $this->normalizeTemplateKey($templateKey);
|
||||
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
|
||||
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => self::TEMPLATES[$key]['enabled'],
|
||||
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeTemplateKey(?string $templateKey): ?string
|
||||
{
|
||||
if ($templateKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = strtolower(trim($templateKey));
|
||||
if ($key === self::TEMPLATE_CUSTOM) {
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
return array_key_exists($key, self::TEMPLATES) ? $key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
*/
|
||||
public function classify(array $permissions, bool $enabled = true): string
|
||||
{
|
||||
$normalized = $this->normalizePermissions($permissions);
|
||||
if (!$enabled || $normalized === []) {
|
||||
return self::TEMPLATE_DEACTIVATED;
|
||||
}
|
||||
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
if (!$template['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized === $this->normalizePermissions($template['permissions'])) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function permissionGroups(array $permissions): array
|
||||
{
|
||||
$permissions = $this->normalizePermissions($permissions);
|
||||
$groups = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
|
||||
if ($capability === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $capability['group'];
|
||||
$groups[$group] ??= [];
|
||||
$groups[$group][] = $capability['capability'];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
if (!isset($groups[$group])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($groups[$group])),
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizePermissions(array $permissions): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($permissions as $permission) {
|
||||
if ($permission instanceof subusers_permission_node_key) {
|
||||
$permission = $permission->name;
|
||||
}
|
||||
if (!is_string($permission)) {
|
||||
continue;
|
||||
}
|
||||
$permission = strtoupper(trim($permission));
|
||||
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
$normalized[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -1453,6 +1453,183 @@ paths:
|
||||
$ref: '#/components/schemas/BirdFlashCallHangupResponse'
|
||||
|
||||
# Subusers (public registration + setup)
|
||||
/superuser/users/{user_id}/subusers:
|
||||
get:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: List subusers for a superuser customer account
|
||||
description: Returns paginated driver access grants for the customer number resolved from the selected user.
|
||||
operationId: listSuperuserUserSubusers
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: integer, minimum: 1, maximum: 1000 }
|
||||
- name: search
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
- name: include_non_enabled
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: boolean }
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped subuser list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserManagementRow'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/summary:
|
||||
get:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Summarize subusers for a superuser customer account
|
||||
operationId: summarizeSuperuserUserSubusers
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped subuser summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SubuserManagementSummary'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/invite:
|
||||
post:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Invite a subuser for a superuser customer account
|
||||
operationId: inviteSuperuserUserSubuser
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, phone_country_code, phone]
|
||||
properties:
|
||||
name: { type: string, minLength: 3, maxLength: 255 }
|
||||
phone_country_code: { type: integer }
|
||||
phone: { type: integer }
|
||||
note: { type: string, nullable: true, maxLength: 65535 }
|
||||
enabled: { type: boolean, default: true }
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Driver invited or linked
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend:
|
||||
post:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Resend a user-scoped subuser invite
|
||||
operationId: resendSuperuserUserSubuserInvite
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: subuser_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
'200':
|
||||
description: Invite resent
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/subusers/grants/{grant_id}:
|
||||
patch:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: Update a user-scoped subuser grant
|
||||
operationId: updateSuperuserUserSubuserGrant
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: grant_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SubuserGrantUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Grant updated
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/subusers:
|
||||
get:
|
||||
tags:
|
||||
@@ -2036,6 +2213,35 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/subusers/permission-templates:
|
||||
get:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: List simplified subuser permission templates
|
||||
description: Returns backend-owned driver access profiles and grouped capability metadata for subuser grants.
|
||||
operationId: listSubuserPermissionTemplates
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Permission templates fetched
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
templates:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionTemplate'
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionGroup'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
# Authentication Endpoints
|
||||
/auth/login:
|
||||
post:
|
||||
@@ -7409,6 +7615,139 @@ paths:
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/superuser/users/{user_id}/vehicles:
|
||||
get:
|
||||
tags: [Vehicles]
|
||||
summary: List vehicles for a selected superuser user
|
||||
operationId: listSuperuserUserVehicles
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- $ref: '#/components/parameters/PageParam'
|
||||
- $ref: '#/components/parameters/PerPageParam'
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped vehicles retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Vehicle'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
post:
|
||||
tags: [Vehicles]
|
||||
summary: Add a vehicle for a selected superuser user
|
||||
operationId: addSuperuserUserVehicle
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [reg, type]
|
||||
properties:
|
||||
reg: { type: string, minLength: 2, maxLength: 12 }
|
||||
type: { type: integer }
|
||||
wash_subscription: { type: boolean, default: false }
|
||||
reference: { type: string, nullable: true, maxLength: 255 }
|
||||
customer_id:
|
||||
type: integer
|
||||
description: Optional guard value; must match the selected user's customer number.
|
||||
responses:
|
||||
'200':
|
||||
description: Vehicle created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Vehicle'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
put:
|
||||
tags: [Vehicles]
|
||||
summary: Edit a vehicle for a selected superuser user
|
||||
operationId: editSuperuserUserVehicle
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: { type: integer, minimum: 1 }
|
||||
reg: { type: string, minLength: 2, maxLength: 12 }
|
||||
type: { type: integer }
|
||||
wash_subscription: { type: boolean }
|
||||
reference: { type: string, nullable: true, maxLength: 255 }
|
||||
customer_id:
|
||||
type: integer
|
||||
description: Optional guard value; moving vehicles between customers is not allowed here.
|
||||
responses:
|
||||
'200':
|
||||
description: Vehicle updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Vehicle'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
delete:
|
||||
tags: [Vehicles]
|
||||
summary: Delete a vehicle for a selected superuser user
|
||||
operationId: deleteSuperuserUserVehicle
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
- name: id
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
'200': { description: Vehicle deleted }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/superuser/users/{user_id}/vehicles/summary:
|
||||
get:
|
||||
tags: [Vehicles]
|
||||
summary: Summarize vehicles for a selected superuser user
|
||||
operationId: summarizeSuperuserUserVehicles
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
'200':
|
||||
description: User-scoped vehicle summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VehicleManagementSummary'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
# Vehicles Endpoints
|
||||
/vehicles:
|
||||
get:
|
||||
@@ -16543,6 +16882,55 @@ components:
|
||||
format: date-time
|
||||
nullable: true
|
||||
|
||||
SubuserManagementRow:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer }
|
||||
username: { type: string, nullable: true }
|
||||
name: { type: string, nullable: true }
|
||||
email: { type: string, format: email, nullable: true }
|
||||
phone_country_code: { type: integer, nullable: true }
|
||||
phone: { type: integer, nullable: true }
|
||||
created_at: { type: string, format: date-time, nullable: true }
|
||||
updated_at: { type: string, format: date-time, nullable: true }
|
||||
suspended_at: { type: string, format: date-time, nullable: true }
|
||||
two_factor_enabled: { type: boolean }
|
||||
setup_required: { type: boolean }
|
||||
invite_accepted: { type: boolean }
|
||||
can_resend_invite: { type: boolean }
|
||||
profile_editable_by_manager: { type: boolean }
|
||||
customer_number: { type: integer }
|
||||
customer_name: { type: string, nullable: true }
|
||||
grant_id: { type: integer, nullable: true }
|
||||
grant_enabled: { type: boolean }
|
||||
grant_note: { type: string, nullable: true }
|
||||
grant_permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin, custom]
|
||||
permission_groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionGroup'
|
||||
grant_created_at: { type: string, format: date-time, nullable: true }
|
||||
grant_updated_at: { type: string, format: date-time, nullable: true }
|
||||
access_state:
|
||||
type: string
|
||||
enum: [active, pending_setup, disabled, inactive]
|
||||
|
||||
SubuserManagementSummary:
|
||||
type: object
|
||||
properties:
|
||||
total: { type: integer }
|
||||
active: { type: integer }
|
||||
pending_setup: { type: integer }
|
||||
disabled: { type: integer }
|
||||
|
||||
SubuserGrantCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
@@ -16567,6 +16955,9 @@ components:
|
||||
description: Optional list of permission keys; defaults will be applied if omitted
|
||||
items:
|
||||
type: string
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
|
||||
SubuserGrantUpdateRequest:
|
||||
type: object
|
||||
@@ -16581,6 +16972,40 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
permission_template_key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
|
||||
SubuserPermissionGroup:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
SubuserPermissionTemplate:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
enum: [deactivated, driver, booking_coordinator, fleet_admin]
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
permissions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
permission_groups:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserPermissionGroup'
|
||||
|
||||
SubuserGrantSummary:
|
||||
type: object
|
||||
@@ -20199,6 +20624,20 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
VehicleManagementSummary:
|
||||
type: object
|
||||
required: [total, wash_subscription, self_service]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
wash_subscription:
|
||||
type: integer
|
||||
minimum: 0
|
||||
self_service:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
Notification:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -6,6 +6,7 @@ use classes\authentication;
|
||||
use classes\economic;
|
||||
use classes\gatewayapi;
|
||||
use classes\response;
|
||||
use classes\subuser_permission_templates_service;
|
||||
use classes\virkdata;
|
||||
use Exception;
|
||||
use modules\virkdata\helpers\virkdata_response;
|
||||
@@ -120,6 +121,27 @@ class subusersRoute
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}|null
|
||||
*/
|
||||
private function parseAccessTemplatePayload(): ?array
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (!self::isParametersSet(['permission_template_key'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$templateKey = (string)self::getParameter('permission_template_key');
|
||||
try {
|
||||
return (new subuser_permission_templates_service())->expandTemplate($templateKey);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$response->error($exception->getMessage(), 400);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeOptionalString(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
@@ -337,6 +359,7 @@ class subusersRoute
|
||||
{
|
||||
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
||||
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
|
||||
$templateService = new subuser_permission_templates_service();
|
||||
$setupRequired = $subuser->requiresSetup();
|
||||
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
|
||||
$inviteAccepted = !$setupRequired;
|
||||
@@ -370,6 +393,8 @@ class subusersRoute
|
||||
'grant_note' => $grant ? $grant->note->value() : null,
|
||||
'grant_permissions' => $grantPermissions,
|
||||
'permissions' => $grantPermissions,
|
||||
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
|
||||
'permission_groups' => $templateService->permissionGroups($grantPermissions),
|
||||
'access_state' => $accessState,
|
||||
];
|
||||
}
|
||||
@@ -465,6 +490,7 @@ class subusersRoute
|
||||
private function buildSuperuserSubuserManagementPayload(array $row): array
|
||||
{
|
||||
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
|
||||
$templateService = new subuser_permission_templates_service();
|
||||
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
|
||||
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
|
||||
|
||||
@@ -497,6 +523,8 @@ class subusersRoute
|
||||
'grant_note' => $row['grant_note'] ?? null,
|
||||
'grant_permissions' => $grantPermissions,
|
||||
'permissions' => $grantPermissions,
|
||||
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
|
||||
'permission_groups' => $templateService->permissionGroups($grantPermissions),
|
||||
'grant_created_at' => $row['grant_created_at'] ?? null,
|
||||
'grant_updated_at' => $row['grant_updated_at'] ?? null,
|
||||
'access_state' => $accessState,
|
||||
@@ -718,6 +746,11 @@ class subusersRoute
|
||||
|
||||
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
|
||||
$updates = [];
|
||||
$templateAccess = $this->parseAccessTemplatePayload();
|
||||
if ($templateAccess !== null) {
|
||||
$updates['enabled'] = $templateAccess['enabled'];
|
||||
$updates['permissions'] = $templateAccess['permissions'];
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['enabled'])) {
|
||||
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
@@ -804,6 +837,7 @@ class subusersRoute
|
||||
$phoneCountryCode = (int)self::getParameter('phone_country_code');
|
||||
$phone = (int)self::getParameter('phone');
|
||||
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
|
||||
$templateAccess = $this->parseAccessTemplatePayload();
|
||||
$enabled = true;
|
||||
if (self::isParametersSet(['enabled'])) {
|
||||
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
@@ -812,6 +846,10 @@ class subusersRoute
|
||||
$permissions = self::isParametersSet(['permissions'])
|
||||
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
|
||||
: null;
|
||||
if ($templateAccess !== null) {
|
||||
$enabled = $templateAccess['enabled'];
|
||||
$permissions = $templateAccess['permissions'];
|
||||
}
|
||||
|
||||
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
|
||||
$response->error('Name must be between 3 and 255 characters long', 400);
|
||||
@@ -993,9 +1031,14 @@ class subusersRoute
|
||||
self::requireType($note, self::type_string());
|
||||
self::requireMaxLength('note', 65535);
|
||||
}
|
||||
$templateAccess = $this->parseAccessTemplatePayload();
|
||||
$permissions = self::isParametersSet(['permissions'])
|
||||
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
|
||||
: null;
|
||||
if ($templateAccess !== null) {
|
||||
$enabled = $templateAccess['enabled'];
|
||||
$permissions = $templateAccess['permissions'];
|
||||
}
|
||||
try {
|
||||
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
|
||||
$response->success(['grant' => $grant->asArray()]);
|
||||
@@ -1033,6 +1076,12 @@ class subusersRoute
|
||||
}
|
||||
}
|
||||
|
||||
$templateAccess = $this->parseAccessTemplatePayload();
|
||||
if ($templateAccess !== null) {
|
||||
$grant->enabled->set($templateAccess['enabled']);
|
||||
$grant->permissions->set($templateAccess['permissions']);
|
||||
}
|
||||
|
||||
// Update fields provided in the request
|
||||
if (self::isParametersSet(['enabled'])) {
|
||||
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
@@ -1103,6 +1152,25 @@ class subusersRoute
|
||||
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
|
||||
]);
|
||||
|
||||
$this->get('/subusers/permission-templates', function () {
|
||||
global $response;
|
||||
$canUseGlobalManagement = self::hasPermission('manage_subuser_grants')
|
||||
|| self::hasPermission('list_subusers')
|
||||
|| self::hasPermission('add_subusers')
|
||||
|| self::hasPermission('edit_subusers');
|
||||
if (!$canUseGlobalManagement) {
|
||||
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
|
||||
}
|
||||
|
||||
$response->success((new subuser_permission_templates_service())->accessModel());
|
||||
}, [
|
||||
'list_own_subusers' => 'List chauffeur permission templates for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
|
||||
'manage_subuser_grants' => 'List chauffeur permission templates for administrative grant management.',
|
||||
'list_subusers' => 'List chauffeur permission templates for superuser management.',
|
||||
'add_subusers' => 'List chauffeur permission templates while inviting chauffeurs.',
|
||||
'edit_subusers' => 'List chauffeur permission templates while editing chauffeur grants.',
|
||||
]);
|
||||
|
||||
$this->post('/subusers', function () {
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
|
||||
@@ -21,6 +21,306 @@ class vehiclesRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private function routePositiveInt(string $name): int
|
||||
{
|
||||
global $response;
|
||||
|
||||
$raw = $this->fromRoute($name);
|
||||
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
|
||||
$response->error('Invalid route parameter', 400);
|
||||
}
|
||||
|
||||
return (int)$raw;
|
||||
}
|
||||
|
||||
private function resolveSuperuserVehicleTargetUser(int $userId): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$targetUser = (new users_o())->select($userId);
|
||||
if (!$targetUser->exists()) {
|
||||
$response->error('User not found', 404);
|
||||
}
|
||||
$targetUser->getObjectProperties();
|
||||
|
||||
$customerNumber = (int)$targetUser->customer_number->value();
|
||||
if ($customerNumber <= 0) {
|
||||
$response->error('Selected user does not have a customer number', 400);
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => (int)$targetUser->id,
|
||||
'customer_number' => $customerNumber,
|
||||
'customer_name' => (string)$targetUser->getCustomerName($customerNumber),
|
||||
];
|
||||
}
|
||||
|
||||
private function addUserScopedVehicleMeta(array $targetUser): void
|
||||
{
|
||||
global $response;
|
||||
|
||||
$response->add_meta('user_context', $targetUser);
|
||||
$response->add_meta('vehicles_summary', $this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
|
||||
}
|
||||
|
||||
private function buildVehiclePayload(array $vehicle): array
|
||||
{
|
||||
return [...(new customer_vehicles_o())->select((int)$vehicle['id'])->asArray()];
|
||||
}
|
||||
|
||||
private function listVehiclesForCustomer(int $customerNumber): array
|
||||
{
|
||||
$vehicles = new customer_vehicles_o();
|
||||
|
||||
return $vehicles->listObjectsWithPaginationIfSet(
|
||||
fn ($vehicle) => $this->buildVehiclePayload($vehicle),
|
||||
$vehicles->forceRestrictFilters([
|
||||
'customer_id' => [$customerNumber],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
private function buildVehicleSummaryForCustomer(int $customerNumber): array
|
||||
{
|
||||
$rows = (new customer_vehicles_o())->getFieldsWhere([
|
||||
'customer_id' => $customerNumber,
|
||||
'deleted_at' => null,
|
||||
], [
|
||||
'id',
|
||||
'wash_subscription',
|
||||
]);
|
||||
|
||||
$summary = [
|
||||
'total' => 0,
|
||||
'wash_subscription' => 0,
|
||||
'self_service' => 0,
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$summary['total']++;
|
||||
if ((int)($row['wash_subscription'] ?? 0) === 1) {
|
||||
$summary['wash_subscription']++;
|
||||
}
|
||||
|
||||
try {
|
||||
$vehicle = (new customer_vehicles_o())->select((int)$row['id']);
|
||||
if ($vehicle->exists() && $vehicle->hasXLVask()) {
|
||||
$summary['self_service']++;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// XLVask availability should not prevent the customer vehicle summary from loading.
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function requireScopedVehicle(int $vehicleId, int $customerNumber): customer_vehicles_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
$vehicle = (new customer_vehicles_o())->select($vehicleId);
|
||||
if (!$vehicle->exists()) {
|
||||
$response->error('Vehicle not found', 404);
|
||||
}
|
||||
$vehicle->getObjectProperties();
|
||||
if ((int)$vehicle->customer_id->value() !== $customerNumber) {
|
||||
$response->error('Vehicle does not belong to selected user', 404);
|
||||
}
|
||||
|
||||
return $vehicle;
|
||||
}
|
||||
|
||||
private function validateOptionalCustomerIdMatches(int $customerNumber): void
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (!self::isParametersSet(['customer_id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$requestedCustomerNumber = (int)self::getParameter('customer_id');
|
||||
self::requireType($requestedCustomerNumber, self::type_int());
|
||||
if ($requestedCustomerNumber !== $customerNumber) {
|
||||
$response->error('Customer number does not match selected user', 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function createVehicleForCustomer(int $customerNumber): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
self::requireParameters([
|
||||
'type',
|
||||
'reg',
|
||||
]);
|
||||
$this->validateOptionalCustomerIdMatches($customerNumber);
|
||||
|
||||
$reference = null;
|
||||
if (self::isParametersSet(['reference']) && !empty(self::getParameter('reference'))) {
|
||||
$reference = (string)self::getParameter('reference');
|
||||
self::requireType($reference, self::type_string());
|
||||
self::requireMinLength('reference', 1);
|
||||
self::requireMaxLength('reference', 255);
|
||||
}
|
||||
|
||||
self::requireType(self::getParameter('reg'), self::type_string());
|
||||
self::requireType(self::getParameter('type'), self::type_int());
|
||||
$subscription = false;
|
||||
if (self::isParametersSet(['wash_subscription'])) {
|
||||
self::requireType(self::getParameter('wash_subscription'), self::type_bool());
|
||||
$subscription = (bool)self::getParameter('wash_subscription');
|
||||
}
|
||||
|
||||
$reg = trim((string)self::getParameter('reg'));
|
||||
self::requireMinLength('reg', 2);
|
||||
self::requireMaxLength('reg', 12);
|
||||
$type = (int)self::getParameter('type');
|
||||
|
||||
$vehicle = new customer_vehicles_o();
|
||||
$vehicle->add($customerNumber, $type, $reg, $subscription, $reference);
|
||||
|
||||
try {
|
||||
(new economic_v2_versioning_service())->recordVehicleSubscriptionVersion(
|
||||
[
|
||||
'vehicle_id' => (int)$vehicle->id,
|
||||
'customer_number' => $customerNumber,
|
||||
'reg' => $reg,
|
||||
'vehicle_type' => $type,
|
||||
'wash_subscription' => $subscription,
|
||||
],
|
||||
date('Y-m-d H:i:s'),
|
||||
'live.vehicle.route',
|
||||
1.0,
|
||||
false,
|
||||
[
|
||||
'route' => '/superuser/users/{user_id}/vehicles',
|
||||
'method' => 'POST',
|
||||
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
|
||||
}
|
||||
|
||||
return $vehicle->asArray();
|
||||
}
|
||||
|
||||
private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$this->validateOptionalCustomerIdMatches($customerNumber);
|
||||
|
||||
$beforeState = [
|
||||
'vehicle_id' => (int)$vehicle->id,
|
||||
'customer_number' => (int)$vehicle->customer_id->value(),
|
||||
'reg' => (string)$vehicle->reg->value(),
|
||||
'vehicle_type' => (int)$vehicle->type->value(),
|
||||
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
|
||||
];
|
||||
|
||||
if (self::isParametersSet(['type'])) {
|
||||
$type = (int)self::getParameter('type');
|
||||
self::requireType($type, self::type_int());
|
||||
self::requireMinValue($type, 0);
|
||||
if ($type === 0) {
|
||||
$vehicle->type->set(0);
|
||||
$vehicle->wash_subscription->set(0);
|
||||
} else {
|
||||
$product = new products_o();
|
||||
$product->select($type);
|
||||
if (!$product->exists() || !$product->subscription_allowed->value()) {
|
||||
$response->error('Invalid type', 400);
|
||||
}
|
||||
$vehicle->type->set($type);
|
||||
}
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['reg'])) {
|
||||
$reg = (string)self::getParameter('reg');
|
||||
self::requireType($reg, self::type_string());
|
||||
self::requireMinLength('reg', 2);
|
||||
self::requireMaxLength('reg', 12);
|
||||
$vehicle->reg->set(preg_replace('/\s+/', '', $reg));
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['wash_subscription'])) {
|
||||
$subscription = (bool)self::getParameter('wash_subscription');
|
||||
self::requireType($subscription, self::type_bool());
|
||||
if ((int)$vehicle->type->value() === 0 && $subscription) {
|
||||
$response->error('Unable to set subscription, type is not set', 400);
|
||||
}
|
||||
$vehicle->wash_subscription->set($subscription ? 1 : 0);
|
||||
}
|
||||
|
||||
if (self::isParametersSet(['reference'])) {
|
||||
if (empty(self::getParameter('reference'))) {
|
||||
$vehicle->reference->nullify();
|
||||
} else {
|
||||
$reference = (string)self::getParameter('reference');
|
||||
self::requireType($reference, self::type_string());
|
||||
self::requireMinLength('reference', 1);
|
||||
self::requireMaxLength('reference', 255);
|
||||
$vehicle->reference->set($reference);
|
||||
}
|
||||
}
|
||||
|
||||
$vehicle->objectChanged();
|
||||
|
||||
$afterState = [
|
||||
'vehicle_id' => (int)$vehicle->id,
|
||||
'customer_number' => (int)$vehicle->customer_id->value(),
|
||||
'reg' => (string)$vehicle->reg->value(),
|
||||
'vehicle_type' => (int)$vehicle->type->value(),
|
||||
'wash_subscription' => (bool)$vehicle->wash_subscription->value(),
|
||||
];
|
||||
|
||||
$versionRelevantChange = (
|
||||
(string)$beforeState['reg'] !== (string)$afterState['reg'] ||
|
||||
(int)$beforeState['vehicle_type'] !== (int)$afterState['vehicle_type'] ||
|
||||
(bool)$beforeState['wash_subscription'] !== (bool)$afterState['wash_subscription']
|
||||
);
|
||||
if ($versionRelevantChange) {
|
||||
try {
|
||||
$versioning = new economic_v2_versioning_service();
|
||||
$effectiveAt = date('Y-m-d H:i:s');
|
||||
if ((string)$beforeState['reg'] !== (string)$afterState['reg']) {
|
||||
$versioning->closeActiveVehicleSubscriptionVersion(
|
||||
(int)$beforeState['customer_number'],
|
||||
(string)$beforeState['reg'],
|
||||
$effectiveAt,
|
||||
'live.vehicle.route',
|
||||
1.0,
|
||||
false,
|
||||
[
|
||||
'route' => '/superuser/users/{user_id}/vehicles',
|
||||
'method' => 'PUT',
|
||||
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
|
||||
'reason' => 'identity_change',
|
||||
]
|
||||
);
|
||||
}
|
||||
$versioning->recordVehicleSubscriptionVersion(
|
||||
$afterState,
|
||||
$effectiveAt,
|
||||
'live.vehicle.route',
|
||||
1.0,
|
||||
false,
|
||||
[
|
||||
'route' => '/superuser/users/{user_id}/vehicles',
|
||||
'method' => 'PUT',
|
||||
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $vehicle->asArray();
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/vehicles', function () {
|
||||
@@ -119,6 +419,93 @@ class vehiclesRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/superuser/users/{user_id}/vehicles', function () {
|
||||
global $response;
|
||||
$this->requirePermission('list_vehicles_other');
|
||||
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
|
||||
$this->addUserScopedVehicleMeta($targetUser);
|
||||
$response->success($this->listVehiclesForCustomer((int)$targetUser['customer_number']));
|
||||
}, [
|
||||
'list_vehicles_other' => 'List vehicles for a selected superuser customer account.',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/users/{user_id}/vehicles/summary', function () {
|
||||
global $response;
|
||||
$this->requirePermission('list_vehicles_other');
|
||||
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
|
||||
$response->add_meta('user_context', $targetUser);
|
||||
$response->success($this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number']));
|
||||
}, [
|
||||
'list_vehicles_other' => 'Summarize vehicles for a selected superuser customer account.',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/users/{user_id}/vehicles', function () {
|
||||
global $response;
|
||||
$this->requirePermission('add_vehicle_other');
|
||||
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
|
||||
$response->add_meta('user_context', $targetUser);
|
||||
$response->success($this->createVehicleForCustomer((int)$targetUser['customer_number']));
|
||||
}, [
|
||||
'add_vehicle_other' => 'Add a vehicle for a selected superuser customer account.',
|
||||
]);
|
||||
|
||||
$this->put('/superuser/users/{user_id}/vehicles', function () {
|
||||
global $response;
|
||||
$this->requirePermission('edit_vehicle_other');
|
||||
self::requireParameters(['id']);
|
||||
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
|
||||
$vehicleId = (int)self::getParameter('id');
|
||||
self::requireType($vehicleId, self::type_int());
|
||||
self::requireMinValue($vehicleId, 1);
|
||||
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
|
||||
$response->add_meta('user_context', $targetUser);
|
||||
$response->success($this->updateScopedVehicle($vehicle, (int)$targetUser['customer_number']));
|
||||
}, [
|
||||
'edit_vehicle_other' => 'Edit a vehicle for a selected superuser customer account.',
|
||||
]);
|
||||
|
||||
$this->delete('/superuser/users/{user_id}/vehicles', function () {
|
||||
global $response;
|
||||
$this->requirePermission('delete_vehicle_other');
|
||||
self::requireParameters(['id']);
|
||||
$targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id'));
|
||||
$vehicleId = (int)self::getParameter('id');
|
||||
self::requireType($vehicleId, self::type_int());
|
||||
self::requireMinValue($vehicleId, 1);
|
||||
$vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']);
|
||||
|
||||
$beforeState = [
|
||||
'customer_number' => (int)$vehicle->customer_id->value(),
|
||||
'reg' => (string)$vehicle->reg->value(),
|
||||
];
|
||||
$vehicle->delete();
|
||||
try {
|
||||
(new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion(
|
||||
(int)$beforeState['customer_number'],
|
||||
(string)$beforeState['reg'],
|
||||
date('Y-m-d H:i:s'),
|
||||
'live.vehicle.route',
|
||||
1.0,
|
||||
false,
|
||||
[
|
||||
'route' => '/superuser/users/{user_id}/vehicles',
|
||||
'method' => 'DELETE',
|
||||
'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
|
||||
}
|
||||
|
||||
$response->add_meta('user_context', $targetUser);
|
||||
$response->success([
|
||||
'success' => true,
|
||||
'message' => 'Vehicle deleted successfully',
|
||||
]);
|
||||
}, [
|
||||
'delete_vehicle_other' => 'Delete a vehicle for a selected superuser customer account.',
|
||||
]);
|
||||
|
||||
$this->post('/vehicles', function () {
|
||||
global $response;
|
||||
$auth = new authentication();
|
||||
|
||||
@@ -112,6 +112,32 @@ it('requires subuser management access before exposing permission nodes', functi
|
||||
expect($authorized->data())->toBeArray()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('requires subuser management access before exposing simplified permission templates', function (): void {
|
||||
api_test_covers('GET /subusers/permission-templates', 'auth');
|
||||
api_test_covers('GET /subusers/permission-templates', 'happy');
|
||||
|
||||
$unauthenticated = api_client()->get('/subusers/permission-templates');
|
||||
$unauthenticated
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
$session = api_fixtures()->createUserSession(['list_own_subusers']);
|
||||
$authorized = api_client()->get('/subusers/permission-templates', $session['headers']);
|
||||
$authorized
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($authorized->data()['templates'] ?? null)->toBeArray()->not->toBeEmpty();
|
||||
expect(array_column($authorized->data()['templates'], 'key'))
|
||||
->toContain('driver')
|
||||
->toContain('booking_coordinator')
|
||||
->toContain('fleet_admin')
|
||||
->toContain('deactivated');
|
||||
expect($authorized->data()['groups'] ?? null)->toBeArray()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('rejects customer subuser listing without own-scope permission', function (): void {
|
||||
api_test_covers('GET /subusers', 'auth');
|
||||
|
||||
@@ -281,6 +307,84 @@ it('lets superusers invite chauffeurs for a selected customer', function (): voi
|
||||
}
|
||||
});
|
||||
|
||||
it('applies simplified driver access templates when inviting and updating chauffeur grants', function (): void {
|
||||
api_test_covers('POST /superuser/subusers/invite', 'happy');
|
||||
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'happy');
|
||||
api_test_covers('PATCH /superuser/users/{user_id}/subusers/grants/{grant_id}', 'failure');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['add_subusers', 'manage_subuser_grants']);
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Template Target Customer']);
|
||||
$phone = 72000000 + ((int)$customer['customer_number'] % 1000000);
|
||||
$createdSubuserId = null;
|
||||
$createdGrantId = null;
|
||||
$setupToken = null;
|
||||
|
||||
try {
|
||||
$invite = api_client()->post('/superuser/subusers/invite', [
|
||||
'customer_number' => (int)$customer['customer_number'],
|
||||
'name' => 'Template Driver',
|
||||
'phone_country_code' => 45,
|
||||
'phone' => $phone,
|
||||
'permission_template_key' => 'booking_coordinator',
|
||||
], $session['headers']);
|
||||
|
||||
$invite
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$payload = $invite->data();
|
||||
$createdSubuserId = isset($payload['subuser']['id']) ? (int)$payload['subuser']['id'] : null;
|
||||
$createdGrantId = isset($payload['grant']['id']) ? (int)$payload['grant']['id'] : null;
|
||||
$setupToken = isset($payload['invite']['setup_token']) ? (string)$payload['invite']['setup_token'] : null;
|
||||
|
||||
expect($payload['subuser']['permission_template_key'] ?? null)->toBe('booking_coordinator');
|
||||
expect($payload['subuser']['grant_permissions'] ?? null)
|
||||
->toContain('BOOKINGS_EDIT')
|
||||
->toContain('BOOKINGS_ADD')
|
||||
->not->toContain('SUBUSERS_ADD');
|
||||
|
||||
$deactivate = api_client()->request(
|
||||
'PATCH',
|
||||
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
|
||||
['permission_template_key' => 'deactivated'],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$deactivate
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($deactivate->data()['subuser']['permission_template_key'] ?? null)->toBe('deactivated');
|
||||
expect($deactivate->data()['grant']['enabled'] ?? null)->toBeFalse();
|
||||
expect($deactivate->data()['grant']['permissions'] ?? null)->toBe([]);
|
||||
|
||||
$invalid = api_client()->request(
|
||||
'PATCH',
|
||||
'/superuser/users/' . $customer['id'] . '/subusers/grants/' . $createdGrantId,
|
||||
['permission_template_key' => 'unknown_template'],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$invalid
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
} finally {
|
||||
if ($setupToken !== null && $setupToken !== '') {
|
||||
(new \objects\subusers_o())->invalidateSetupToken($setupToken);
|
||||
}
|
||||
if ($createdGrantId !== null) {
|
||||
api_test_runtime()->db()->query('DELETE FROM `subuser_grants` WHERE `id` = ' . $createdGrantId);
|
||||
}
|
||||
if ($createdSubuserId !== null) {
|
||||
api_test_runtime()->db()->query('DELETE FROM `tokens` WHERE `user_id` = ' . $createdSubuserId . " AND `type` = 'AUTH_TOKEN_SUBUSER'");
|
||||
api_test_runtime()->db()->query('DELETE FROM `subusers` WHERE `id` = ' . $createdSubuserId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('lists and summarizes chauffeurs through the user-scoped superuser route', function (): void {
|
||||
$session = api_fixtures()->createUserSession(['list_subusers']);
|
||||
$targetCustomer = api_fixtures()->createUser([
|
||||
|
||||
@@ -194,3 +194,181 @@ it('returns a null vehicle last_order_id when no order with items exists', funct
|
||||
'last_order_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists and summarizes vehicles through the user-scoped superuser route', function (): void {
|
||||
api_test_covers('GET /superuser/users/{user_id}/vehicles', 'happy');
|
||||
api_test_covers('GET /superuser/users/{user_id}/vehicles/summary', 'happy');
|
||||
|
||||
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Customer']);
|
||||
$otherUser = api_fixtures()->createUser(['display_name' => 'Other Vehicle Customer']);
|
||||
$vehicle = api_fixtures()->createVehicle([
|
||||
'customer_id' => $targetUser['customer_number'],
|
||||
'type' => 53,
|
||||
'reg' => 'SCOPEDV1',
|
||||
'wash_subscription' => 1,
|
||||
'reference' => 'Scoped fleet',
|
||||
]);
|
||||
api_fixtures()->createVehicle([
|
||||
'customer_id' => $otherUser['customer_number'],
|
||||
'type' => 53,
|
||||
'reg' => 'OTHERV1',
|
||||
'wash_subscription' => 1,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['list_vehicles_other']);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/superuser/users/' . $targetUser['id'] . '/vehicles?page=1&limit=20',
|
||||
$session['headers']
|
||||
);
|
||||
$summary = api_client()->get(
|
||||
'/superuser/users/' . $targetUser['id'] . '/vehicles/summary',
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
$summary
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$rows = $response->data();
|
||||
expect(array_column($rows, 'reg'))->toContain('SCOPEDV1');
|
||||
expect(array_column($rows, 'reg'))->not->toContain('OTHERV1');
|
||||
expect($rows[0])->toMatchArray([
|
||||
'id' => $vehicle['id'],
|
||||
'customer_id' => $targetUser['customer_number'],
|
||||
'reg' => 'SCOPEDV1',
|
||||
]);
|
||||
expect($response->meta()['user_context'])->toMatchArray([
|
||||
'user_id' => $targetUser['id'],
|
||||
'customer_number' => $targetUser['customer_number'],
|
||||
]);
|
||||
expect($summary->data())->toMatchArray([
|
||||
'total' => 1,
|
||||
'wash_subscription' => 1,
|
||||
'self_service' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates vehicles through the user-scoped superuser route and rejects mismatched customer ids', function (): void {
|
||||
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'happy');
|
||||
api_test_covers('POST /superuser/users/{user_id}/vehicles', 'failure');
|
||||
|
||||
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Create Customer']);
|
||||
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Mismatch Customer']);
|
||||
$session = api_fixtures()->createUserSession(['add_vehicle_other']);
|
||||
|
||||
$created = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
|
||||
'type' => 53,
|
||||
'reg' => 'SCOPEDADD',
|
||||
'wash_subscription' => true,
|
||||
'reference' => 'Created from user detail',
|
||||
], $session['headers']);
|
||||
$mismatch = api_client()->post('/superuser/users/' . $targetUser['id'] . '/vehicles', [
|
||||
'customer_id' => $otherUser['customer_number'],
|
||||
'type' => 53,
|
||||
'reg' => 'BADSCOPED',
|
||||
], $session['headers']);
|
||||
|
||||
$created
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
$mismatch
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertError();
|
||||
|
||||
$vehicleId = (int)($created->data()['id'] ?? 0);
|
||||
expect($vehicleId)->toBeGreaterThan(0);
|
||||
expect($created->data())->toMatchArray([
|
||||
'customer_id' => $targetUser['customer_number'],
|
||||
'reg' => 'SCOPEDADD',
|
||||
'wash_subscription' => true,
|
||||
'reference' => 'Created from user detail',
|
||||
]);
|
||||
api_fixtures()->cleanupDeleteById('customer_vehicles', $vehicleId);
|
||||
api_fixtures()->cleanupDeleteWhere('customer_vehicle_subscription_versions', ['vehicle_id' => $vehicleId]);
|
||||
});
|
||||
|
||||
it('edits and deletes only matching customer vehicles through the user-scoped superuser route', function (): void {
|
||||
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'happy');
|
||||
api_test_covers('PUT /superuser/users/{user_id}/vehicles', 'failure');
|
||||
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'happy');
|
||||
api_test_covers('DELETE /superuser/users/{user_id}/vehicles', 'failure');
|
||||
|
||||
$targetUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Edit Customer']);
|
||||
$otherUser = api_fixtures()->createUser(['display_name' => 'Scoped Vehicle Guard Customer']);
|
||||
$vehicle = api_fixtures()->createVehicle([
|
||||
'customer_id' => $targetUser['customer_number'],
|
||||
'type' => 53,
|
||||
'reg' => 'SCOPEDIT',
|
||||
'wash_subscription' => 0,
|
||||
]);
|
||||
$otherVehicle = api_fixtures()->createVehicle([
|
||||
'customer_id' => $otherUser['customer_number'],
|
||||
'type' => 53,
|
||||
'reg' => 'SCOPEBAD',
|
||||
'wash_subscription' => 0,
|
||||
]);
|
||||
$deletableVehicle = api_fixtures()->createVehicle([
|
||||
'customer_id' => $targetUser['customer_number'],
|
||||
'type' => 53,
|
||||
'reg' => 'SCOPEDEL',
|
||||
'wash_subscription' => 0,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession(['edit_vehicle_other', 'delete_vehicle_other']);
|
||||
|
||||
$edited = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
|
||||
'id' => $vehicle['id'],
|
||||
'reg' => 'SCOPEDOK',
|
||||
'reference' => 'Updated reference',
|
||||
], $session['headers']);
|
||||
$moved = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
|
||||
'id' => $vehicle['id'],
|
||||
'customer_id' => $otherUser['customer_number'],
|
||||
], $session['headers']);
|
||||
$foreignEdit = api_client()->put('/superuser/users/' . $targetUser['id'] . '/vehicles', [
|
||||
'id' => $otherVehicle['id'],
|
||||
'reg' => 'SHOULDFAIL',
|
||||
], $session['headers']);
|
||||
$deleted = api_client()->delete(
|
||||
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $deletableVehicle['id'],
|
||||
$session['headers']
|
||||
);
|
||||
$foreignDelete = api_client()->delete(
|
||||
'/superuser/users/' . $targetUser['id'] . '/vehicles?id=' . $otherVehicle['id'],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$edited
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
$moved
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertError();
|
||||
$foreignEdit
|
||||
->assertStatus(404)
|
||||
->assertEnvelope()
|
||||
->assertError();
|
||||
$deleted
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
$foreignDelete
|
||||
->assertStatus(404)
|
||||
->assertEnvelope()
|
||||
->assertError();
|
||||
|
||||
expect($edited->data())->toMatchArray([
|
||||
'id' => $vehicle['id'],
|
||||
'customer_id' => $targetUser['customer_number'],
|
||||
'reg' => 'SCOPEDOK',
|
||||
'reference' => 'Updated reference',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/subuser_permission_templates_service.php');
|
||||
app_require('modules/subusers/helpers/subusers_permission_node_key.php');
|
||||
|
||||
use classes\subuser_permission_templates_service;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
|
||||
it('exposes driver access templates with valid subuser permission node keys', function (): void {
|
||||
$service = new subuser_permission_templates_service();
|
||||
$templates = $service->templates();
|
||||
|
||||
expect(array_column($templates, 'key'))
|
||||
->toContain(subuser_permission_templates_service::TEMPLATE_DEACTIVATED)
|
||||
->toContain(subuser_permission_templates_service::TEMPLATE_DRIVER)
|
||||
->toContain(subuser_permission_templates_service::TEMPLATE_BOOKING_COORDINATOR)
|
||||
->toContain(subuser_permission_templates_service::TEMPLATE_FLEET_ADMIN);
|
||||
|
||||
foreach ($templates as $template) {
|
||||
foreach ($template['permissions'] as $permission) {
|
||||
expect(subusers_permission_node_key::tryFrom($permission))->not->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('expands and classifies practical driver access templates', function (): void {
|
||||
$service = new subuser_permission_templates_service();
|
||||
|
||||
$driver = $service->expandTemplate(subuser_permission_templates_service::TEMPLATE_DRIVER);
|
||||
$deactivated = $service->expandTemplate(subuser_permission_templates_service::TEMPLATE_DEACTIVATED);
|
||||
|
||||
expect($driver['enabled'])->toBeTrue()
|
||||
->and($driver['permissions'])
|
||||
->toContain('VEHICLES_LIST')
|
||||
->toContain('BOOKINGS_ADD')
|
||||
->and($service->classify($driver['permissions'], true))
|
||||
->toBe(subuser_permission_templates_service::TEMPLATE_DRIVER)
|
||||
->and($deactivated['enabled'])
|
||||
->toBeFalse()
|
||||
->and($deactivated['permissions'])
|
||||
->toBe([])
|
||||
->and($service->classify(['VEHICLES_LIST'], false))
|
||||
->toBe(subuser_permission_templates_service::TEMPLATE_DEACTIVATED)
|
||||
->and($service->classify(['VEHICLES_LIST'], true))
|
||||
->toBe(subuser_permission_templates_service::TEMPLATE_CUSTOM);
|
||||
});
|
||||
Reference in New Issue
Block a user