diff --git a/.gitignore b/.gitignore index 18884fd4e16..b655037991d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ client/junit.xml !/reports/sql_queries !/reports/.keep credstash.log +client/mocks/webex-mocks/webex-mock.json # Ignore MS Office temp files ~$* diff --git a/app/controllers/organizations/users_controller.rb b/app/controllers/organizations/users_controller.rb index 6a125576a84..4d6cd9eb2a5 100644 --- a/app/controllers/organizations/users_controller.rb +++ b/app/controllers/organizations/users_controller.rb @@ -32,6 +32,7 @@ def update adjust_admin_rights end + update_user_meeting_type render json: { users: json_administered_users([user_to_modify]) }, status: :ok end @@ -67,6 +68,14 @@ def adjust_admin_rights end end + def update_user_meeting_type + new_meeting_type = params.dig(:attributes, :meeting_type) + + if organization["url"] == HearingsManagement.singleton.url && new_meeting_type + OrganizationsUser.update_user_conference_type(user_to_modify, new_meeting_type) + end + end + def organization_url params[:organization_url] end diff --git a/app/jobs/virtual_hearings/conference_client.rb b/app/jobs/virtual_hearings/conference_client.rb new file mode 100644 index 00000000000..3b2d79e27dc --- /dev/null +++ b/app/jobs/virtual_hearings/conference_client.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module VirtualHearings::ConferenceClient + def client + case RequestStore.store[:current_user].meeting_type + when "pexip" + @client ||= PexipService.new( + host: ENV["PEXIP_MANAGEMENT_NODE_HOST"], + port: ENV["PEXIP_MANAGEMENT_NODE_PORT"], + user_name: ENV["PEXIP_USERNAME"], + password: ENV["PEXIP_PASSWORD"], + client_host: ENV["PEXIP_CLIENT_HOST"] + ) + when "webex" + msg = "You hit the Webex Service!" + fail Caseflow::Error::WebexApiError, message: msg + # @client ||= WebexService.new( + # host: ENV["WEBEX_MANAGEMENT_NODE_HOST"], + # port: ENV["WEBEX_MANAGEMENT_NODE_PORT"], + # user_name: ENV["WEBEX_USERNAME"], + # password: ENV["WEBEX_PASSWORD"], + # client_host: ENV["WEBEX_CLIENT_HOST"] + # ) + else + msg = "Meeting type for the user is invalid" + fail Caseflow::Error::MeetingTypeNotFoundError, message: msg + end + end +end diff --git a/app/jobs/virtual_hearings/conference_job.rb b/app/jobs/virtual_hearings/conference_job.rb index 6bdc9ca03db..c92dbde5345 100644 --- a/app/jobs/virtual_hearings/conference_job.rb +++ b/app/jobs/virtual_hearings/conference_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class VirtualHearings::ConferenceJob < ApplicationJob - include VirtualHearings::PexipClient + include VirtualHearings::ConferenceClient private diff --git a/app/jobs/virtual_hearings/create_conference_job.rb b/app/jobs/virtual_hearings/create_conference_job.rb index 36d04423091..a16e0f1674e 100644 --- a/app/jobs/virtual_hearings/create_conference_job.rb +++ b/app/jobs/virtual_hearings/create_conference_job.rb @@ -138,12 +138,12 @@ def create_conference "[#{virtual_hearing.hearing_id}])..." ) - pexip_response = create_pexip_conference + create_conference_response = create_new_conference - Rails.logger.info("Pexip response: #{pexip_response.inspect}") + Rails.logger.info("Create Conference Response: #{create_conference_response.inspect}") - if pexip_response.error - error_display = pexip_error_display(pexip_response) + if create_conference_response.error + error_display = error_display(create_conference_response) Rails.logger.error("CreateConferenceJob failed: #{error_display}") @@ -151,12 +151,12 @@ def create_conference DataDogService.increment_counter(metric_name: "created_conference.failed", **create_conference_datadog_tags) - fail pexip_response.error + fail create_conference_response.error end DataDogService.increment_counter(metric_name: "created_conference.successful", **create_conference_datadog_tags) - virtual_hearing.update(conference_id: pexip_response.data[:conference_id]) + virtual_hearing.update(conference_id: create_conference_response.data[:conference_id]) end end @@ -172,11 +172,11 @@ def send_emails(email_type) end end - def pexip_error_display(response) + def error_display(response) "(#{response.error.code}) #{response.error.message}" end - def create_pexip_conference + def create_new_conference client.create_conference( host_pin: virtual_hearing.host_pin, guest_pin: virtual_hearing.guest_pin, diff --git a/app/jobs/virtual_hearings/pexip_client.rb b/app/jobs/virtual_hearings/pexip_client.rb deleted file mode 100644 index 70e5023f662..00000000000 --- a/app/jobs/virtual_hearings/pexip_client.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module VirtualHearings::PexipClient - def client - @client ||= PexipService.new( - host: ENV["PEXIP_MANAGEMENT_NODE_HOST"], - port: ENV["PEXIP_MANAGEMENT_NODE_PORT"], - user_name: ENV["PEXIP_USERNAME"], - password: ENV["PEXIP_PASSWORD"], - client_host: ENV["PEXIP_CLIENT_HOST"] - ) - end -end diff --git a/app/models/organizations_user.rb b/app/models/organizations_user.rb index 189d98de647..23ce4f1c4b4 100644 --- a/app/models/organizations_user.rb +++ b/app/models/organizations_user.rb @@ -28,6 +28,12 @@ def remove_admin_rights_from_user(user, organization) existing_record(user, organization)&.update!(admin: false) end + def update_user_conference_type(user, new_meeting_type) + if user.meeting_type + user.update!(meeting_type: new_meeting_type) + end + end + def remove_user_from_organization(user, organization) if user_is_judge_of_team?(user, organization) fail Caseflow::Error::ActionForbiddenError, message: COPY::JUDGE_TEAM_REMOVE_JUDGE_ERROR diff --git a/app/models/serializers/work_queue/administered_user_serializer.rb b/app/models/serializers/work_queue/administered_user_serializer.rb index 61b86097292..f4ffcf0e3a9 100644 --- a/app/models/serializers/work_queue/administered_user_serializer.rb +++ b/app/models/serializers/work_queue/administered_user_serializer.rb @@ -11,4 +11,5 @@ class WorkQueue::AdministeredUserSerializer < WorkQueue::UserSerializer params[:organization].dvc&.eql?(object) end end + attribute :meeting_type end diff --git a/app/views/queue/index.html.erb b/app/views/queue/index.html.erb index e0ba7a1038a..507e51addaa 100644 --- a/app/views/queue/index.html.erb +++ b/app/views/queue/index.html.erb @@ -24,6 +24,7 @@ canEditCavcDashboards: current_user.can_edit_cavc_dashboards?, canViewCavcDashboards: current_user.can_view_cavc_dashboards?, userIsCobAdmin: ClerkOfTheBoard.singleton.admins.include?(current_user), + meetingType: current_user.meeting_type, featureToggles: { collect_video_and_central_emails: FeatureToggle.enabled?(:collect_video_and_central_emails, user: current_user), enable_hearing_time_slots: FeatureToggle.enabled?(:enable_hearing_time_slots, user: current_user), @@ -53,7 +54,8 @@ cavc_remand_granted_substitute_appellant: FeatureToggle.enabled?(:cavc_remand_granted_substitute_appellant, user: current_user), cavc_dashboard_workflow: FeatureToggle.enabled?(:cavc_dashboard_workflow, user: current_user), cc_appeal_workflow: FeatureToggle.enabled?(:cc_appeal_workflow, user: current_user), - cc_vacatur_visibility: FeatureToggle.enabled?(:cc_vacatur_visibility, user: current_user) + cc_vacatur_visibility: FeatureToggle.enabled?(:cc_vacatur_visibility, user: current_user), + conference_selection_visibility: FeatureToggle.enabled?(:conference_selection_visibility,user: current_user) } }) %> <% end %> diff --git a/client/COPY.json b/client/COPY.json index 9bd85705be1..f02e14b0e86 100644 --- a/client/COPY.json +++ b/client/COPY.json @@ -772,6 +772,7 @@ "USER_MANAGEMENT_GIVE_USER_ADMIN_RIGHTS_BUTTON_TEXT": "Add admin rights", "USER_MANAGEMENT_REMOVE_USER_ADMIN_RIGHTS_BUTTON_TEXT": "Remove admin rights", "USER_MANAGEMENT_REMOVE_USER_FROM_ORG_BUTTON_TEXT": "Remove from team", + "USER_MANAGEMENT_SELECT_HEARINGS_CONFERENCE_TYPE": "Schedule hearings using:", "MEMBERSHIP_REQUEST_ACTION_SUCCESS_TITLE": "You successfully %s %s's request", "MEMBERSHIP_REQUEST_ACTION_SUCCESS_MESSAGE": "The user was %s regular member access to %s.", "VHA_MEMBERSHIP_REQUEST_AUTOMATIC_VHA_ACCESS_NOTE": "Note: If you are requesting specialized access and are not a member of the general VHA group, you will automatically be given access to the general VHA group if your request is approved.", diff --git a/client/app/queue/OrganizationUsers.jsx b/client/app/queue/OrganizationUsers.jsx index 3497cf30792..1453040d758 100644 --- a/client/app/queue/OrganizationUsers.jsx +++ b/client/app/queue/OrganizationUsers.jsx @@ -16,6 +16,7 @@ import { LOGO_COLORS } from '../constants/AppConstants'; import COPY from '../../COPY'; import LoadingDataDisplay from '../components/LoadingDataDisplay'; import MembershipRequestTable from './MembershipRequestTable'; +import SelectConferenceTypeRadioField from './SelectConferenceTypeRadioField'; const userStyle = css({ margin: '.5rem 0 .5rem', @@ -38,11 +39,17 @@ const buttonStyle = css({ const buttonContainerStyle = css({ borderBottom: '1rem solid gray', borderWidth: '1px', - padding: '.5rem 0 2rem', + padding: '.5rem 7rem 2rem 0', + display: 'flex', + justifyContent: 'space-between', + flexWrap: 'wrap' }); const listStyle = css({ listStyle: 'none' }); +const radioContainerStyle = css({ + padding: '-5rem 5rem 2rem 2rem', +}); export default class OrganizationUsers extends React.PureComponent { constructor(props) { @@ -248,18 +255,34 @@ export default class OrganizationUsers extends React.PureComponent { const style = i === 0 ? topUserStyle : userStyle; return -
  • {this.formatName(user)} - { judgeTeam && admin && ( {COPY.USER_MANAGEMENT_JUDGE_LABEL} ) } - { dvcTeam && dvc && ( {COPY.USER_MANAGEMENT_DVC_LABEL} ) } - { judgeTeam && !admin && ( {COPY.USER_MANAGEMENT_ATTORNEY_LABEL} ) } - { (judgeTeam || dvcTeam) && admin && ( {COPY.USER_MANAGEMENT_ADMIN_LABEL} ) } -
  • - { (judgeTeam || dvcTeam) && admin ? -
    : -
    - { (judgeTeam || dvcTeam) ? '' : this.adminButton(user, admin) } - { this.removeUserButton(user) } -
    } +
    + +
    ; }); @@ -285,10 +308,10 @@ export default class OrganizationUsers extends React.PureComponent {

    {COPY.USER_MANAGEMENT_EDIT_USER_IN_ORG_LABEL}

    @@ -363,5 +386,6 @@ export default class OrganizationUsers extends React.PureComponent { } OrganizationUsers.propTypes = { - organization: PropTypes.string + organization: PropTypes.string, + conferenceSelectionVisibility: PropTypes.bool }; diff --git a/client/app/queue/QueueApp.jsx b/client/app/queue/QueueApp.jsx index f1b6d83a52c..3875a82e704 100644 --- a/client/app/queue/QueueApp.jsx +++ b/client/app/queue/QueueApp.jsx @@ -17,6 +17,7 @@ import { setCanEditCavcDashboards, setCanViewCavcDashboards, setFeatureToggles, + setMeetingType, setUserId, setUserRole, setUserCssId, @@ -111,6 +112,7 @@ class QueueApp extends React.PureComponent { this.props.setCanEditAod(this.props.canEditAod); this.props.setCanEditNodDate(this.props.userCanViewEditNodDate); this.props.setUserIsCobAdmin(this.props.userIsCobAdmin); + this.props.setMeetingType(this.props.meetingType); this.props.setCanEditCavcRemands(this.props.canEditCavcRemands); this.props.setCanEditCavcDashboards(this.props.canEditCavcDashboards); this.props.setCanViewCavcDashboards(this.props.canViewCavcDashboards); @@ -582,7 +584,9 @@ class QueueApp extends React.PureComponent { }; routedOrganizationUsers = (props) => ( - + ); routedTeamManagement = (props) => ; @@ -755,15 +759,15 @@ class QueueApp extends React.PureComponent { // eslint-disable-next-line default-case switch (this.props.reviewActionType) { - case DECISION_TYPES.OMO_REQUEST: - reviewActionType = 'OMO'; - break; - case DECISION_TYPES.DRAFT_DECISION: - reviewActionType = 'Draft Decision'; - break; - case DECISION_TYPES.DISPATCH: - reviewActionType = 'to Dispatch'; - break; + case DECISION_TYPES.OMO_REQUEST: + reviewActionType = 'OMO'; + break; + case DECISION_TYPES.DRAFT_DECISION: + reviewActionType = 'Draft Decision'; + break; + case DECISION_TYPES.DISPATCH: + reviewActionType = 'to Dispatch'; + break; } return `Draft Decision | Submit ${reviewActionType}`; @@ -1215,7 +1219,7 @@ class QueueApp extends React.PureComponent { /> ({ @@ -1430,6 +1436,7 @@ const mapDispatchToProps = (dispatch) => setCanEditAod, setCanEditNodDate, setUserIsCobAdmin, + setMeetingType, setCanEditCavcRemands, setCanEditCavcDashboards, setCanViewCavcDashboards, diff --git a/client/app/queue/SelectConferenceTypeRadioField.jsx b/client/app/queue/SelectConferenceTypeRadioField.jsx new file mode 100644 index 00000000000..805c88f26c1 --- /dev/null +++ b/client/app/queue/SelectConferenceTypeRadioField.jsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import ApiUtil from '../util/ApiUtil'; + +import RadioField from '../components/RadioField'; +import COPY from '../../COPY'; + +const radioOptions = [ + { displayText: 'Pexip', + value: 'pexip' }, + { displayText: 'Webex', + value: 'webex' } +]; + +const SelectConferenceTypeRadioField = ({ name, meetingType, organization, user }) => { + const [value, setValue] = useState(meetingType); + + const modifyConferenceType = (newMeetingType) => { + const payload = { data: { ...user, attributes: { ...user.attributes, meeting_type: newMeetingType } } }; + + ApiUtil.patch(`/organizations/${organization}/users/${user.id}`, payload); + }; + + return ( + <> + setValue(newValue) || modifyConferenceType(newValue))} + vertical + /> + ); +}; + +SelectConferenceTypeRadioField.propTypes = { + name: PropTypes.string, + onClick: PropTypes.func, + meetingType: PropTypes.string, + organization: PropTypes.string, + user: PropTypes.shape({ + id: PropTypes.string, + attributes: PropTypes.object + }) +}; + +export default SelectConferenceTypeRadioField; diff --git a/client/app/queue/uiReducer/uiActions.js b/client/app/queue/uiReducer/uiActions.js index c428b105da2..1cabce79991 100644 --- a/client/app/queue/uiReducer/uiActions.js +++ b/client/app/queue/uiReducer/uiActions.js @@ -48,6 +48,13 @@ export const setUserIsCobAdmin = (userIsCobAdmin) => ({ } }); +export const setMeetingType = (meetingType) => ({ + type: ACTIONS.SET_MEETING_TYPE, + payload: { + meetingType + } +}); + export const setCanViewOvertimeStatus = (canViewOvertimeStatus) => ({ type: ACTIONS.SET_CAN_VIEW_OVERTIME_STATUS, payload: { diff --git a/client/app/queue/uiReducer/uiConstants.js b/client/app/queue/uiReducer/uiConstants.js index 212902a93d2..7b6c9d8743b 100644 --- a/client/app/queue/uiReducer/uiConstants.js +++ b/client/app/queue/uiReducer/uiConstants.js @@ -8,6 +8,7 @@ export const ACTIONS = { SET_FEEDBACK_URL: 'SET_FEEDBACK_URL', SET_TARGET_USER: 'SET_TARGET_USER', + SET_MEETING_TYPE: 'SET_MEETING_TYPE', HIGHLIGHT_INVALID_FORM_ITEMS: 'HIGHLIGHT_INVALID_FORM_ITEMS', RESET_ERROR_MESSAGES: 'RESET_ERROR_MESSAGES', diff --git a/client/app/queue/uiReducer/uiReducer.js b/client/app/queue/uiReducer/uiReducer.js index 2fbbf3871b4..711ff4e750d 100644 --- a/client/app/queue/uiReducer/uiReducer.js +++ b/client/app/queue/uiReducer/uiReducer.js @@ -99,6 +99,10 @@ const workQueueUiReducer = (state = initialState, action = {}) => { return update(state, { userIsCobAdmin: { $set: action.payload.userIsCobAdmin } }); + case ACTIONS.SET_MEETING_TYPE: + return update(state, { + meetingType: { $set: action.payload.meetingType } + }); case ACTIONS.SET_CAN_EDIT_CAVC_REMANDS: return update(state, { canEditCavcRemands: { $set: action.payload.canEditCavcRemands } diff --git a/client/mocks/webex-mocks/README.md b/client/mocks/webex-mocks/README.md new file mode 100644 index 00000000000..cca7ce34640 --- /dev/null +++ b/client/mocks/webex-mocks/README.md @@ -0,0 +1,59 @@ +Setup json server + +Step 1: Open a terminal + +Step 2: Navigate to the caseflow/client + +step 3: Run command: [npm install json-server] or [yarn add json-server] + +If the [npm install json-server] or [yarn add json-server] returns an error that resembles: + +error standard@17.1.0: The engine "node" is incompatible with this module. Expected version "^12.22.0 || ^14.17.0 || >=16.0.0". Got "15.1.0" + +extra steps may need to be taken. + +for brevity These instructions will follow the happy path. While in the client directory in terminal: +[nodenv install 14.21.2] +[nodenv local 14.21.2] + +If for any reason you want to go back to the original nodenv that was used prior to this change you can run, [nodenv local 12.13.0] + +If it all succeeds you can attempt the [npm install json-server] or [yarn add json-server] once again. + +This time with no issue. +given that the install goes as expected you can continue following the rest of the directions. + +If there are still issues in getting this to operate as expected, See your tech lead for asssisstance. + +step 4: Make sure casfelow application is running + +step 5: Autogenerate test data, run this command: npm run generate-webex(This will also create the json file) + +step 6: Run command: npm run webex-server + +\*info: You will recieve all available routes within the terminal under 'Resources' + +\*info: port must be set on a different port to run due to caseflow running on port 3000 + +step 7: Open a browser window in chrome and navigate to localhost:3050 [You will get the default page] + +\*info: You can use any api endpoint software you want like Postman, but a good lightweight vs code ext. is [Thunder Client] + +\*info: reference guides +[https://github.com/typicode/json-server/blob/master/README.md] + +Tutorial Resources: +[https://www.youtube.com/watch?v=_1kNqAybxW0&list=PLC3y8-rFHvwhc9YZIdqNL5sWeTCGxF4ya&index=1] + +To create a meeting the request body must have all of the keys and hit this endpoint? +[http://localhost:3050/fake.api-usgov.webex.com/v1/meetings] + +Get all conferencelinks with this endpoint +[http://localhost:3050/api/v1/conference-links] + +Javascript API call Fetch/Axios examples +[https://jsonplaceholder.typicode.com/] + + + + diff --git a/client/mocks/webex-mocks/meetingData.js b/client/mocks/webex-mocks/meetingData.js new file mode 100644 index 00000000000..0ed5772a76e --- /dev/null +++ b/client/mocks/webex-mocks/meetingData.js @@ -0,0 +1,37 @@ +const faker = require('faker'); + +const generateMeetingData = (response) => { + + return { + id: faker.random.uuid(), + jwt: { + sub: response.jwt.sub, + Nbf: response.jwt.Nbf, + Exp: response.jwt.Exp, + flow: { + id: faker.random.uuid(), + data: [ + { + uri: `${faker.internet.userName()}@intadmin.room.wbx2.com`, + }, + { + uri: `${faker.internet.userName()}@intadmin.room.wbx2.com`, + }, + ], + }, + }, + aud: faker.random.uuid(), + numGuest: faker.random.number({ min: 1, max: 10 }), + numHost: 1, + provideShortUrls: faker.random.boolean(), + verticalType: faker.company.catchPhrase(), + loginUrlForHost: faker.random.boolean(), + jweAlg: 'PBES2-HS512+A256KW', + saltLength: faker.random.number({ min: 1, max: 16 }), + iterations: faker.random.number({ min: 500, max: 2000 }), + enc: 'A256GCM', + jwsAlg: 'HS512', + }; +}; + +module.exports = generateMeetingData; diff --git a/client/mocks/webex-mocks/routes.json b/client/mocks/webex-mocks/routes.json new file mode 100644 index 00000000000..76516817fb9 --- /dev/null +++ b/client/mocks/webex-mocks/routes.json @@ -0,0 +1,5 @@ + +{ + "/api/v1/conference-links": "/conferenceLinks", + "/api/v1/conference-links/:id": "/conferenceLinks/:id" + } diff --git a/client/mocks/webex-mocks/webex-mock-generator.js b/client/mocks/webex-mocks/webex-mock-generator.js new file mode 100644 index 00000000000..6f8a108f5ee --- /dev/null +++ b/client/mocks/webex-mocks/webex-mock-generator.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const faker = require('faker'); +const generateMeetingData = require('./meetingData.js'); + +const generateConferenceLinks = () => { + let webexLinks = []; + + for (let id = 1; id <= 10; id++) { + const startDate = new Date('2021-01-01T00:00:00Z'); + const endDate = new Date('2023-01-01T00:00:00Z'); + + const randomStartDate = faker.date.between(startDate, endDate); + const randomEndDate = new Date(randomStartDate.getTime()); + + randomEndDate.setHours(randomEndDate.getHours() + 1); + + let startTime = randomStartDate.toISOString().replace('Z', ''); + let endTime = randomEndDate.toISOString().replace('Z', ''); + + let subject = faker.lorem.words(); + + let updatedValues = { + jwt: { + sub: subject, + Nbf: startTime, + Exp: endTime + } + }; + + webexLinks.push(generateMeetingData(updatedValues)); + } + + return webexLinks; +}; + +// Generate the data +const data = { + conferenceLinks: generateConferenceLinks(), + // ... other data models +}; + +// Check if the script is being run directly +if (require.main === module) { + fs.writeFileSync( + 'mocks/webex-mocks/webex-mock.json', + JSON.stringify(data, null, 2) + ); + // eslint-disable-next-line no-console + console.log("Generated new data in webex-mock.json"); +} diff --git a/client/mocks/webex-mocks/webex-mock-server.js b/client/mocks/webex-mocks/webex-mock-server.js new file mode 100644 index 00000000000..884bf9b2810 --- /dev/null +++ b/client/mocks/webex-mocks/webex-mock-server.js @@ -0,0 +1,231 @@ +const jsonServer = require('json-server'); +const server = jsonServer.create(); +const path = require('path'); +const router = jsonServer.router( + path.join('mocks/webex-mocks/webex-mock.json') +); +const generateMeetingData = require('./meetingData.js'); + +const middlewares = jsonServer.defaults(); +const routesRewrite = require('./routes.json'); + +server.use(middlewares); +server.use(jsonServer.bodyParser); + +// Apply the routes rewrites +server.use(jsonServer.rewriter(routesRewrite)); + +// Custom error routes and handlers +server.get('/error-400', (req, res) => { + res.status(400).json({ + message: 'The request was invalid or cannot be otherwise served.', + }); +}); + +server.get('/error-401', (req, res) => { + res.status(401).json({ + message: 'Authentication credentials were missing or incorrect.', + }); +}); + +server.get('/error-403', (req, res) => { + res.status(403).json({ + message: + 'The request is understood, but it has been refused or access is not allowed', + }); +}); + +server.get('/error-405', (req, res) => { + res.status(405).json({ + message: + 'The request was made to a resource using an HTTP request method that is not supported.', + }); +}); + +server.get('/error-409', (req, res) => { + res.status(409).json({ + message: + 'The request could not be processed because it conflicts with some established rule of the system.', + }); +}); + +server.get('/error-410', (req, res) => { + res.status(410).json({ + message: 'The requested resource is no longer available.', + }); +}); + +server.get('/error-415', (req, res) => { + res.status(415).json({ + message: + 'The request was made to a resource without specifying a media type or used a media type that is not supported.', + }); +}); + +server.get('/error-423', (req, res) => { + res.status(423).json({ + message: 'The requested resource is temporarily unavailable', + }); +}); + +server.get('/error-428', (req, res) => { + res.status(428).json({ + message: + 'File(s) cannot be scanned for malware and need to be force downloaded.', + }); +}); + +server.get('/error-429', (req, res) => { + res.status(429).json({ + message: + 'Too many requests have been sent in a given amount of time and the request has been rate limited.', + }); +}); + +server.get('/error-500', (req, res) => { + res.status(500).json({ + message: 'Something went wrong on the server.', + }); +}); + +server.get('/error-502', (req, res) => { + res.status(502).json({ + message: + 'The server received an invalid response from an upstream server while processing the request.', + }); +}); + +server.get('/error-503', (req, res) => { + res.status(503).json({ + message: 'Server is overloaded with requests. Try again later.', + }); +}); + +server.get('/error-504', (req, res) => { + res.status(504).json({ + message: + 'An upstream server failed to respond on time. If your query uses max parameter, please try to reduce it.', + }); +}); + +server.get('/health-check-yellow', (req, res) => { + res.status(200).json({ + status: 'yellow', + }); +}); + +server.get('/health-check-red', (req, res) => { + res.status(200).json({ + status: 'red', + }); +}); + +server.get('/health-check-green', (req, res) => { + res.status(200).json({ + status: 'green', + }); +}); + +const requiredKeys = [ + 'jwt', + 'aud', + 'numGuest', + 'numHost', + 'provideShortUrls', + 'verticalType', + 'loginUrlForHost', + 'jweAlg', + 'saltLength', + 'iterations', + 'enc', + 'jwsAlg' +]; + +server.post('/fake.api-usgov.webex.com/v1/meetings', (req, res) => { + const requestBody = req.body; + + // Check if all required keys are present + const missingKeys = requiredKeys.filter((key) => !(key in requestBody)); + + if (missingKeys.length > 0) { + res.status(400).json({ message: 'Missing required keys', missingKeys }); + } else if (!requestBody.jwt.sub || !requestBody.jwt.Nbf || !requestBody.jwt.Exp) { + res.status(400).json({ + message: 'Missing required params', + }); + } else { + + const db = router.db; + const conferenceLinks = db.get('conferenceLinks'); + + // Add generateMeetingData object to conferenceLinks + conferenceLinks.push(generateMeetingData(requestBody)).write(); + + res.status(200).json(generateMeetingData(requestBody)); + } +}); + +server.use(router); + +const errorRoutes = [ + '/error-400', + '/error-401', + '/error-403', + '/error-404', + '/error-405', + '/error-409', + '/error-410', + '/error-415', + '/error-423', + '/error-428', + '/error-429', + '/error-500', + '/error-502', + '/error-503', + '/error-504', + '/health-check-yellow', + '/health-check-red', + '/health-check-green', +]; + +server.listen(3050, () => { + /* eslint-disable no-console */ + console.log(' \\{^_^}/ hi!\n'); + console.log(' Loading mocks/webex-mocks/webex-mock.json'); + console.log(' Done\n'); + + console.log(' Resources:'); + + // Original routes from the database state + const originalRoutes = Object.keys(router.db.getState()); + + // Rewritten routes based on the routes.json rewrites + const rewrittenRoutes = originalRoutes.map((route) => { + for (let key in routesRewrite) { + if (routesRewrite[key] === `/${route}`) { + // returning the custom path + return key; + } + } + + return `/${route}`; + }); + + rewrittenRoutes.forEach((route) => { + console.log(` http://localhost:3050${route}`); + }); + + console.log('\n Error Routes:'); + errorRoutes.forEach((route) => { + console.log(` ${route}`); + }); + + console.log('\n Home'); + console.log(' http://localhost:3050'); + + console.log( + '\n Type s + enter at any time to create a snapshot of the database' + ); + console.log('Watching...'); + /* eslint-enable no-console */ +}); diff --git a/client/package.json b/client/package.json index cc2ba8fbd77..2c59c1ca2a0 100644 --- a/client/package.json +++ b/client/package.json @@ -25,7 +25,9 @@ "dev": "yarn run dev:clear-build && NODE_ENV=development yarn run build -w", "dev:hot": "webpack-dev-server", "storybook": "start-storybook -p 6006", - "build:storybook": "build-storybook -o ../public/storybook" + "build:storybook": "build-storybook -o ../public/storybook", + "webex-server": "node mocks/webex-mocks/webex-mock-server", + "generate-webex": "node mocks/webex-mocks/webex-mock-generator.js" }, "cacheDirectories": [ "node_modules", diff --git a/client/test/app/queue/SelectConferenceTypeRadioField.test.js b/client/test/app/queue/SelectConferenceTypeRadioField.test.js new file mode 100644 index 00000000000..dd36e4f4343 --- /dev/null +++ b/client/test/app/queue/SelectConferenceTypeRadioField.test.js @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ApiUtil from 'app/util/ApiUtil'; + +import SelectConferenceTypeRadioField from 'app/queue/SelectConferenceTypeRadioField'; + +const createSpy = () => jest.spyOn(ApiUtil, 'patch'). + mockImplementation(() => jest.fn(() => Promise.resolve( + { + body: { } + } + ))); + +const defaults = { + name: 'field1', + value: '1', + options: [ + { displayText: 'Pexip', + value: 'pexip' }, + { displayText: 'Webex', + value: 'webex' }, + ], +}; + +describe('SelectConferenceTypeRadioField', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setupComponent = (props = { + user: { + attributes: { + id: 1 + } + }, + meetingType: 'pexip', + organization: 'my org' + }) => { + const utils = render( + + ); + const inputs = utils.getAllByRole('radio'); + + return { + inputs, + ...utils, + }; + }; + + it('renders correctly', async () => { + const { container } = setupComponent(); + + expect(container).toMatchSnapshot(); + }); + + it('changes values by radio button selected', () => { + let requestPatchSpy = createSpy(); + + setupComponent(); + + const webexRadioButton = screen.getByRole('radio', { name: 'Webex' }); + const pexipRadioButton = screen.getByRole('radio', { name: 'Pexip' }); + + expect(webexRadioButton).not.toHaveAttribute('checked', ''); + expect(pexipRadioButton).toHaveAttribute('checked', ''); + + userEvent.click(webexRadioButton); + + expect(requestPatchSpy.mock.calls[0][1].data.attributes.meeting_type).toBe('webex'); + + userEvent.click(pexipRadioButton); + + expect(requestPatchSpy.mock.calls[1][1].data.attributes.meeting_type).toBe('pexip'); + }); +}); diff --git a/client/test/app/queue/__snapshots__/SelectConferenceTypeRadioField.test.js.snap b/client/test/app/queue/__snapshots__/SelectConferenceTypeRadioField.test.js.snap new file mode 100644 index 00000000000..40d135d3387 --- /dev/null +++ b/client/test/app/queue/__snapshots__/SelectConferenceTypeRadioField.test.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectConferenceTypeRadioField renders correctly 1`] = ` +
    +
    + + + Schedule hearings using: + + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +`; diff --git a/client/test/app/queue/organizationUsers/OrganizationUsers.test.js b/client/test/app/queue/organizationUsers/OrganizationUsers.test.js new file mode 100644 index 00000000000..9eee34d1726 --- /dev/null +++ b/client/test/app/queue/organizationUsers/OrganizationUsers.test.js @@ -0,0 +1,122 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import OrganizationUsers from 'app/queue/OrganizationUsers'; +import ApiUtil from 'app/util/ApiUtil'; + +jest.mock('app/util/ApiUtil'); + +describe('Conference Selection Visibility Feature Toggle', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + ApiUtil.get.mockResolvedValue({ + body: { + organization_name: 'Hearing Admin', + judge_team: false, + dvc_team: false, + organization_users: { + data: [ + { + id: '126', + type: 'administered_user', + attributes: { + css_id: 'BVASORANGE', + full_name: 'Felicia BuildAndEditHearingSchedule Orange', + email: null, + admin: false, + dvc: null, + }, + }, + { + id: '2000001601', + type: 'administered_user', + attributes: { + css_id: 'AMBRISVACO', + full_name: 'Gail Maggio V', + email: 'juli@stroman-kertzmann.net', + admin: true, + dvc: null, + }, + }, + ], + }, + membership_requests: [], + isVhaOrg: false, + }, + }); + it('Finds component by Text when conferenceSelectionVisibility is false', async () => { + const conferenceSelectionVisibilityValue = false; + + const { findAllByText } = render( + + ); + const nestedText = await findAllByText('Webex'); + + expect(nestedText[0]).toBeInTheDocument(); + }); + + it('Component does not render when conferenceSelectionVisibility is true', async () => { + const conferenceSelectionVisibilityValue = true; + + const { queryAllByText } = render( + + ); + const nestedText = await queryAllByText('Webex'); + + expect(nestedText).toHaveLength(0); + }); + + it('Component does not render when orginization_name is not Hearing Admin', async () => { + const conferenceSelectionVisibilityValue = false; + + ApiUtil.get.mockResolvedValue({ + body: { + organization_name: 'Hearing Management', + judge_team: false, + dvc_team: false, + organization_users: { + data: [ + { + id: '126', + type: 'administered_user', + attributes: { + css_id: 'BVASORANGE', + full_name: 'Felicia BuildAndEditHearingSchedule Orange', + email: null, + admin: false, + dvc: null, + }, + }, + { + id: '2000001601', + type: 'administered_user', + attributes: { + css_id: 'AMBRISVACO', + full_name: 'Gail Maggio V', + email: 'juli@stroman-kertzmann.net', + admin: true, + dvc: null, + }, + }, + ], + }, + membership_requests: [], + isVhaOrg: false, + }, + }); + + const { queryAllByText } = render( + + ); + const nestedTextWebex = await queryAllByText('Webex'); + + expect(nestedTextWebex).toHaveLength(0); + }); +}); diff --git a/config/initializers/pexip.rb b/config/initializers/pexip.rb index 0b84da94fb0..0cc6eb3bd58 100644 --- a/config/initializers/pexip.rb +++ b/config/initializers/pexip.rb @@ -1 +1 @@ -PexipService = (!ApplicationController.dependencies_faked? ? ExternalApi::PexipService : Fakes::PexipService) +PexipService = (ApplicationController.dependencies_faked? ? Fakes::PexipService : ExternalApi::PexipService) diff --git a/db/migrate/20230726201514_add_meeting_type_to_users.rb b/db/migrate/20230726201514_add_meeting_type_to_users.rb new file mode 100644 index 00000000000..b07b732c95f --- /dev/null +++ b/db/migrate/20230726201514_add_meeting_type_to_users.rb @@ -0,0 +1,9 @@ +class AddMeetingTypeToUsers < Caseflow::Migration + def up + add_column :users, :meeting_type, :varChar, default: "pexip", comment: "Video Conferencing Application Type" + end + + def down + remove_column :users, :meeting_type + end +end diff --git a/db/migrate/20230726203030_add_meeting_type_to_virtual_hearings.rb b/db/migrate/20230726203030_add_meeting_type_to_virtual_hearings.rb new file mode 100644 index 00000000000..6b8f6b1e1c6 --- /dev/null +++ b/db/migrate/20230726203030_add_meeting_type_to_virtual_hearings.rb @@ -0,0 +1,9 @@ +class AddMeetingTypeToVirtualHearings < Caseflow::Migration + def up + add_column :virtual_hearings, :meeting_type, :varChar, default: "pexip", comment: "Video Conferencing Application Type" + end + + def down + remove_column :virtual_hearings, :meeting_type + end +end diff --git a/db/migrate/20230726203750_add_meeting_type_to_conference_links.rb b/db/migrate/20230726203750_add_meeting_type_to_conference_links.rb new file mode 100644 index 00000000000..dc0713e3f35 --- /dev/null +++ b/db/migrate/20230726203750_add_meeting_type_to_conference_links.rb @@ -0,0 +1,9 @@ +class AddMeetingTypeToConferenceLinks < Caseflow::Migration + def up + add_column :conference_links, :meeting_type, :varChar, default: "pexip", comment: "Video Conferencing Application Type" + end + + def down + remove_column :conference_links, :meeting_type + end +end diff --git a/db/schema.rb b/db/schema.rb index 2f02f9c82e3..06240150d9a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -556,6 +556,7 @@ t.string "host_link", comment: "Conference link generated from external conference service" t.integer "host_pin", comment: "Pin for the host of the conference to get into the conference" t.string "host_pin_long", limit: 8, comment: "Generated host pin stored as a string" + t.string "meeting_type", default: "pexip", comment: "Video Conferencing Application Type" t.datetime "updated_at", comment: "Date and Time record was last updated" t.bigint "updated_by_id", comment: "user id of the user to last update the record. FK on the User table" t.index ["created_by_id"], name: "index_created_by_id" @@ -597,6 +598,8 @@ t.string "diagnostic_code", comment: "If a decision resulted in a rating, this is the rating issue's diagnostic code." t.string "disposition", comment: "The disposition for a decision issue. Dispositions made in Caseflow and dispositions made in VBMS can have different values." t.date "end_product_last_action_date", comment: "After an end product gets synced with a status of CLR (cleared), the end product's last_action_date is saved on any decision issues that are created as a result. This is used as a proxy for decision date for non-rating issues that are processed in VBMS because they don't have a rating profile date, and the exact decision date is not available." + t.boolean "mst_status", default: false, comment: "Indicates if decision issue is related to Military Sexual Trauma (MST)" + t.boolean "pact_status", default: false, comment: "Indicates if decision issue is related to Promise to Address Comprehensive Toxics (PACT) Act" t.string "participant_id", null: false, comment: "The Veteran's participant id." t.string "percent_number", comment: "percent_number from RatingIssue (prcntNo from Rating Profile)" t.string "rating_issue_reference_id", comment: "Identifies the specific issue on the rating that resulted from the decision issue (a rating issue can be connected to multiple contentions)." @@ -1472,9 +1475,13 @@ t.string "ineligible_reason", comment: "The reason for a Request Issue being ineligible. If a Request Issue has an ineligible_reason, it is still captured, but it will not get a contention in VBMS or a decision." t.boolean "is_predocket_needed", comment: "Indicates whether or not an issue has been selected to go to the pre-docket queue opposed to normal docketing." t.boolean "is_unidentified", comment: "Indicates whether a Request Issue is unidentified, meaning it wasn't found in the list of contestable issues, and is not a new nonrating issue. Contentions for unidentified issues are created on a rating End Product if processed in VBMS but without the issue description, and someone is required to edit it in Caseflow before proceeding with the decision." + t.boolean "mst_status", default: false, comment: "Indicates if issue is related to Military Sexual Trauma (MST)" + t.text "mst_status_update_reason_notes", comment: "The reason for why Request Issue is Military Sexual Trauma (MST)" t.string "nonrating_issue_category", comment: "The category selected for nonrating request issues. These vary by business line." t.string "nonrating_issue_description", comment: "The user entered description if the issue is a nonrating issue" t.text "notes", comment: "Notes added by the Claims Assistant when adding request issues. This may be used to capture handwritten notes on the form, or other comments the CA wants to capture." + t.boolean "pact_status", default: false, comment: "Indicates if issue is related to Promise to Address Comprehensive Toxics (PACT) Act" + t.text "pact_status_update_reason_notes", comment: "The reason for why Request Issue is Promise to Address Comprehensive Toxics (PACT) Act" t.string "ramp_claim_id", comment: "If a rating issue was created as a result of an issue intaken for a RAMP Review, it will be connected to the former RAMP issue by its End Product's claim ID." t.datetime "rating_issue_associated_at", comment: "Timestamp when a contention and its contested rating issue are associated in VBMS." t.string "split_issue_status", comment: "If a request issue is part of a split, on_hold status applies to the original request issues while active are request issues on splitted appeals" @@ -1485,6 +1492,8 @@ t.datetime "updated_at", comment: "Automatic timestamp whenever the record changes." t.string "vacols_id", comment: "The vacols_id of the legacy appeal that had an issue found to match the request issue." t.integer "vacols_sequence_id", comment: "The vacols_sequence_id, for the specific issue on the legacy appeal which the Claims Assistant determined to match the request issue on the Decision Review. A combination of the vacols_id (for the legacy appeal), and vacols_sequence_id (for which issue on the legacy appeal), is required to identify the issue being opted-in." + t.boolean "vbms_mst_status", default: false, comment: "Indicates if issue is related to Military Sexual Trauma (MST) and was imported from VBMS" + t.boolean "vbms_pact_status", default: false, comment: "Indicates if issue is related to Promise to Address Comprehensive Toxics (PACT) Act and was imported from VBMS" t.boolean "verified_unidentified_issue", comment: "A verified unidentified issue allows an issue whose rating data is missing to be intaken as a regular rating issue. In order to be marked as verified, a VSR needs to confirm that they were able to find the record of the decision for the issue." t.string "veteran_participant_id", comment: "The veteran participant ID. This should be unique in upstream systems and used in the future to reconcile duplicates." t.index ["closed_at"], name: "index_request_issues_on_closed_at" @@ -1510,6 +1519,8 @@ t.integer "edited_request_issue_ids", comment: "An array of the request issue IDs that were edited during this request issues update", array: true t.string "error", comment: "The error message if the last attempt at processing the request issues update was not successful." t.datetime "last_submitted_at", comment: "Timestamp for when the processing for the request issues update was last submitted. Used to determine how long to continue retrying the processing job. Can be reset to allow for additional retries." + t.integer "mst_edited_request_issue_ids", comment: "An array of the request issue IDs that were updated to be associated with MST in request issues update", array: true + t.integer "pact_edited_request_issue_ids", comment: "An array of the request issue IDs that were updated to be associated with PACT in request issues update", array: true t.datetime "processed_at", comment: "Timestamp for when the request issue update successfully completed processing." t.bigint "review_id", null: false, comment: "The ID of the decision review edited." t.string "review_type", null: false, comment: "The type of the decision review edited." @@ -1558,6 +1569,26 @@ t.index ["sent_by_id"], name: "index_sent_hearing_email_events_on_sent_by_id" end + create_table "special_issue_changes", force: :cascade do |t| + t.bigint "appeal_id", null: false, comment: "AMA or Legacy Appeal ID that the issue is tied to" + t.string "appeal_type", null: false, comment: "Appeal Type (Appeal or LegacyAppeal)" + t.string "change_category", null: false, comment: "Type of change that occured to the issue (Established Issue, Added Issue, Edited Issue, Removed Issue)" + t.datetime "created_at", null: false, comment: "Date the special issue change was made" + t.string "created_by_css_id", null: false, comment: "CSS ID of the user that made the special issue change" + t.bigint "created_by_id", null: false, comment: "User ID of the user that made the special issue change" + t.bigint "decision_issue_id", comment: "ID of the decision issue that had a special issue change from its corresponding request issue" + t.bigint "issue_id", null: false, comment: "ID of the issue that was changed" + t.boolean "mst_from_vbms", comment: "Indication that the MST status originally came from VBMS on intake" + t.string "mst_reason_for_change", comment: "Reason for changing the MST status on an issue" + t.boolean "original_mst_status", null: false, comment: "Original MST special issue status of the issue" + t.boolean "original_pact_status", null: false, comment: "Original PACT special issue status of the issue" + t.boolean "pact_from_vbms" + t.string "pact_reason_for_change", comment: "Reason for changing the PACT status on an issue" + t.bigint "task_id", null: false, comment: "Task ID of the IssueUpdateTask or EstablishmentTask used to log this issue in the case timeline" + t.boolean "updated_mst_status", comment: "Updated MST special issue status of the issue" + t.boolean "updated_pact_status", comment: "Updated PACT special issue status of the issue" + end + create_table "special_issue_lists", comment: "Associates special issues to an AMA or legacy appeal for Caseflow Queue. Caseflow Dispatch uses special issues stored in legacy_appeals. They are intentionally disconnected.", force: :cascade do |t| t.bigint "appeal_id", comment: "The ID of the appeal associated with this record" t.string "appeal_type", comment: "The type of appeal associated with this record" @@ -1779,6 +1810,7 @@ t.string "email" t.string "full_name" t.datetime "last_login_at", comment: "The last time the user-agent (browser) provided session credentials; see User.from_session for precision" + t.string "meeting_type", default: "pexip", comment: "Video Conferencing Application Type" t.string "roles", array: true t.string "selected_regional_office" t.string "station_id", null: false @@ -1944,6 +1976,7 @@ t.string "host_pin_long", limit: 8, comment: "Change the host pin to store a longer pin with the # sign trailing" t.string "judge_email", comment: "Judge's email address" t.boolean "judge_email_sent", default: false, null: false, comment: "Whether or not a notification email was sent to the judge" + t.string "meeting_type", default: "pexip", comment: "Video Conferencing Application Type" t.string "representative_email", comment: "Veteran's representative's email address" t.boolean "representative_email_sent", default: false, null: false, comment: "Whether or not a notification email was sent to the veteran's representative" t.datetime "representative_reminder_sent_at", comment: "The datetime the last reminder email was sent to the representative." diff --git a/lib/caseflow/error.rb b/lib/caseflow/error.rb index df77673dd3b..70f85bd5d25 100644 --- a/lib/caseflow/error.rb +++ b/lib/caseflow/error.rb @@ -405,11 +405,16 @@ class MissingRequiredFieldError < VacolsRepositoryError; end class IdtApiError < StandardError; end class InvalidOneTimeKey < IdtApiError; end - class PexipApiError < SerializableError; end + class ConferenceCreationError < SerializableError; end + class MeetingTypeNotFoundError < ConferenceCreationError; end + + class PexipApiError < ConferenceCreationError; end class PexipNotFoundError < PexipApiError; end class PexipBadRequestError < PexipApiError; end class PexipMethodNotAllowedError < PexipApiError; end + class WebexApiError < ConferenceCreationError; end + class WorkModeCouldNotUpdateError < StandardError; end class VirtualHearingConversionFailed < SerializableError diff --git a/spec/jobs/virtual_hearings/create_conference_job_spec.rb b/spec/jobs/virtual_hearings/create_conference_job_spec.rb index ea4297cedfb..db5d7384523 100644 --- a/spec/jobs/virtual_hearings/create_conference_job_spec.rb +++ b/spec/jobs/virtual_hearings/create_conference_job_spec.rb @@ -97,6 +97,18 @@ expect(virtual_hearing.guest_pin.to_s.length).to eq(11) end + it "fails when meeting type is webex" do + current_user.update!(meeting_type: "webex") + + expect { subject.perform_now }.to raise_exception(Caseflow::Error::WebexApiError) + end + + it "fails when a meeting type is neither pexip nor webex" do + current_user.update!(meeting_type: "say whaaaat") + + expect { subject.perform_now }.to raise_exception(Caseflow::Error::MeetingTypeNotFoundError) + end + include_examples "confirmation emails are sent" include_examples "sent email event objects are created" diff --git a/spec/models/organizations_user_spec.rb b/spec/models/organizations_user_spec.rb index bc148bd7a4f..f68b294517d 100644 --- a/spec/models/organizations_user_spec.rb +++ b/spec/models/organizations_user_spec.rb @@ -114,4 +114,18 @@ end end end + + describe ".update_user_conference_type" do + let(:meeting_type) { user.meeting_type } + let(:new_meeting_type) { "webex" } + + subject { OrganizationsUser.update_user_conference_type(user, new_meeting_type) } + + context "when meeting type exists" do + it "should set meeting type to equal new meeting type" do + subject + expect(meeting_type).to eq(new_meeting_type) + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7b25a645c36..6c5b35355d5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -158,7 +158,8 @@ :display_name => css_id.upcase, "name" => "Tom Brady", "status" => Constants.USER_STATUSES.active, - "status_updated_at" => nil + "status_updated_at" => nil, + "meeting_type" => "pexip" } end