Add subuser permission templates service and related tests

This commit is contained in:
Jeppe Bundgaard
2026-07-08 11:49:40 +02:00
parent f26a427510
commit 6b7592921d
8 changed files with 1937 additions and 0 deletions
+439
View File
@@ -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;
}
}
+439
View File
@@ -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;
+387
View File
@@ -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);
});