diff --git a/openapi.yaml b/openapi.yaml index ab47b8e3..e1ba7362 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: diff --git a/services/nginx/app/classes/subuser_permission_templates_service.php b/services/nginx/app/classes/subuser_permission_templates_service.php new file mode 100644 index 00000000..6abbe49d --- /dev/null +++ b/services/nginx/app/classes/subuser_permission_templates_service.php @@ -0,0 +1,276 @@ +}> + */ + 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 + */ + 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 + */ + private const GROUP_ORDER = [ + 'vehicles', + 'selfserve', + 'bookings', + 'orders', + 'driver_management', + ]; + + /** + * @return array + */ + public function accessModel(): array + { + return [ + 'templates' => $this->templates(), + 'groups' => $this->groups(), + ]; + } + + /** + * @return array> + */ + 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}> + */ + 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} + */ + 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 $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 $permissions + * @return array}> + */ + 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 $permissions + * @return array + */ + 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; + } +} diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index cf156d42..d6894b93 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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: diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 9d859204..f6a1ade9 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -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}|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; diff --git a/services/nginx/app/routes/vehiclesRoute.php b/services/nginx/app/routes/vehiclesRoute.php index 10ea2caa..28e3e35e 100644 --- a/services/nginx/app/routes/vehiclesRoute.php +++ b/services/nginx/app/routes/vehiclesRoute.php @@ -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(); diff --git a/services/nginx/app/tests/Api/SubusersApiTest.php b/services/nginx/app/tests/Api/SubusersApiTest.php index 02bce244..baae664c 100644 --- a/services/nginx/app/tests/Api/SubusersApiTest.php +++ b/services/nginx/app/tests/Api/SubusersApiTest.php @@ -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([ diff --git a/services/nginx/app/tests/Api/VehiclesApiTest.php b/services/nginx/app/tests/Api/VehiclesApiTest.php index b113823a..d09e03be 100644 --- a/services/nginx/app/tests/Api/VehiclesApiTest.php +++ b/services/nginx/app/tests/Api/VehiclesApiTest.php @@ -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', + ]); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserPermissionTemplatesTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserPermissionTemplatesTest.php new file mode 100644 index 00000000..e04960ce --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserPermissionTemplatesTest.php @@ -0,0 +1,46 @@ +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); +});