From 494ef042baf9580000d2fb40f6b8c9d50370102b Mon Sep 17 00:00:00 2001 From: YEgorLu Date: Mon, 16 Feb 2026 13:15:42 +0500 Subject: [PATCH 1/6] feat(condo): DOMA-12905 add mutation to send voip start message to all verified residents on address + unit --- .../access/SendVoIPStartMessageService.js | 19 + apps/condo/domains/miniapp/gql.js | 7 + .../schema/SendVoIPStartMessageService.js | 447 +++++++++++++++ .../SendVoIPStartMessageService.test.js | 515 ++++++++++++++++++ apps/condo/domains/miniapp/schema/index.js | 3 + .../domains/miniapp/utils/testSchema/index.js | 21 + apps/condo/domains/miniapp/utils/voip.js | 59 ++ apps/condo/lang/en/en.json | 6 + apps/condo/lang/es/es.json | 6 + apps/condo/lang/ru/ru.json | 6 + apps/condo/schema.graphql | 109 ++++ apps/condo/schema.ts | 88 +++ 12 files changed, 1286 insertions(+) create mode 100644 apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js create mode 100644 apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js create mode 100644 apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js create mode 100644 apps/condo/domains/miniapp/utils/voip.js diff --git a/apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js new file mode 100644 index 00000000000..916053e02f6 --- /dev/null +++ b/apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js @@ -0,0 +1,19 @@ +const { throwAuthenticationError } = require('@open-condo/keystone/apolloErrorFormatter') + +const { SERVICE } = require('@condo/domains/user/constants/common') + +async function canSendVoIPStartMessage ({ args: { data }, authentication: { item: user } }) { + if (!user) return throwAuthenticationError() + if (user.deletedAt) return false + if (user.isAdmin) return true + + if (user.type === SERVICE) { + // TODO(YEgorLu): add access to B2C Service User by B2CAppProperty + } + + return false +} + +module.exports = { + canSendVoIPStartMessage, +} \ No newline at end of file diff --git a/apps/condo/domains/miniapp/gql.js b/apps/condo/domains/miniapp/gql.js index 00aefcfd902..a9215783d34 100644 --- a/apps/condo/domains/miniapp/gql.js +++ b/apps/condo/domains/miniapp/gql.js @@ -29,6 +29,12 @@ const SEND_B2C_APP_PUSH_MESSAGE_MUTATION = gql` } ` +const SEND_VOIP_START_MESSAGE_MUTATION = gql` + mutation sendVoIPStartMessage ($data: SendVoIPStartMessageInput!) { + result: sendVoIPStartMessage(data: $data) { verifiedContactsCount createdMessagesCount erroredMessagesCount } + } +` + const B2B_APP_NEWS_SHARING_CONFIG_FIELDS = `{ publishUrl previewUrl pushNotificationSettings customFormUrl getRecipientsUrl getRecipientsCountersUrl icon { publicUrl } previewPicture { publicUrl } name ${COMMON_FIELDS} }` const B2BAppNewsSharingConfig = generateGqlQueries('B2BAppNewsSharingConfig', B2B_APP_NEWS_SHARING_CONFIG_FIELDS) @@ -135,5 +141,6 @@ module.exports = { B2BAppRoleWithoutEmployeeRole, B2BAppPosIntegrationConfig, B2CAppAccessRightSet, + SEND_VOIP_START_MESSAGE_MUTATION, /* AUTOGENERATE MARKER */ } diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js new file mode 100644 index 00000000000..d8dcabf1ac1 --- /dev/null +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js @@ -0,0 +1,447 @@ +const get = require('lodash/get') +const omit = require('lodash/omit') + +const conf = require('@open-condo/config') +const { GQLError, GQLErrorCode: { BAD_USER_INPUT } } = require('@open-condo/keystone/errors') +const { getLogger } = require('@open-condo/keystone/logging') +const { checkDvAndSender } = require('@open-condo/keystone/plugins/dvAndSender') +const { GQLCustomSchema, find, getByCondition } = require('@open-condo/keystone/schema') +const { i18n } = require('@open-condo/locales/loader') + +const { COMMON_ERRORS } = require('@condo/domains/common/constants/errors') +const access = require('@condo/domains/miniapp/access/SendVoIPStartMessageService') +const { + PROPERTY_NOT_FOUND_ERROR, + APP_NOT_FOUND_ERROR, + DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, + DEFAULT_NOTIFICATION_WINDOW_DURATION_IN_SECONDS, +} = require('@condo/domains/miniapp/constants') +const { B2CAppProperty } = require('@condo/domains/miniapp/utils/serverSchema') +const { setCallStatus, CALL_STATUS_START_SENT } = require('@condo/domains/miniapp/utils/voip') +const { + MESSAGE_META, + VOIP_INCOMING_CALL_MESSAGE_TYPE, MESSAGE_SENDING_STATUS, +} = require('@condo/domains/notification/constants/constants') +const { sendMessage } = require('@condo/domains/notification/utils/serverSchema') +const { FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE } = require('@condo/domains/property/constants/common') +const { RESIDENT } = require('@condo/domains/user/constants/common') +const { RedisGuard } = require('@condo/domains/user/utils/serverSchema/guards') + +const CACHE_TTL = { + DEFAULT: DEFAULT_NOTIFICATION_WINDOW_DURATION_IN_SECONDS, + [VOIP_INCOMING_CALL_MESSAGE_TYPE]: 2, +} + +/** + * If debug app is set and debug app settings are configured, then user can send push messages without creating B2CApp first. + * This is useful for testing and development, but it should be turned off on production + */ +const DEBUG_APP_ID = conf.MINIAPP_PUSH_MESSAGE_DEBUG_APP_ID +const DEBUG_APP_ENABLED = !!DEBUG_APP_ID +const DEBUG_APP_SETTINGS = DEBUG_APP_ENABLED ? Object.freeze(JSON.parse(conf.MINIAPP_PUSH_MESSAGE_DEBUG_APP_SETTINGS)) : {} +const ALLOWED_UNIT_TYPES = [FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE] + +const redisGuard = new RedisGuard() + +const logger = getLogger() + +const SERVICE_NAME = 'sendVoIPStartMessage' +const ERRORS = { + PROPERTY_NOT_FOUND: { + query: SERVICE_NAME, + variable: ['data', 'addressKey'], + code: BAD_USER_INPUT, + type: PROPERTY_NOT_FOUND_ERROR, + message: 'Unable to find Property or B2CAppProperty by provided addressKey', + messageForUser: `api.miniapp.${SERVICE_NAME}.PROPERTY_NOT_FOUND`, + }, + APP_NOT_FOUND: { + mutation: SERVICE_NAME, + variable: ['data', 'addressKey'], + code: BAD_USER_INPUT, + type: APP_NOT_FOUND_ERROR, + message: 'Unable to find B2CApp.', + messageForUser: `api.miniapp.${SERVICE_NAME}.APP_NOT_FOUND`, + }, + DV_VERSION_MISMATCH: { + ...COMMON_ERRORS.DV_VERSION_MISMATCH, + mutation: SERVICE_NAME, + }, + WRONG_SENDER_FORMAT: { + ...COMMON_ERRORS.WRONG_SENDER_FORMAT, + mutation: SERVICE_NAME, + }, +} + +const logInfo = ({ b2cAppId, callId, stats, errors }) => { + logger.info({ msg: `${SERVICE_NAME} stats`, entityName: 'B2CApp', entityId: b2cAppId, data: { callId, stats }, err: errors }) +} + +const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageService', { + types: [ + { + access: true, + type: 'enum VoIPType {' + + '"""' + + 'Makes mobile app use it\'s call app instead of B2CApp\'s' + + '"""' + + 'sip' + + '}', + }, + { + access: true, + type: 'input VoIPPanel {' + + '"""' + + 'Dtfm command for panel' + + '"""' + + 'dtfmCommand: String!' + + '"""' + + 'Name of a panel to be displayed' + + '"""' + + 'name: String!' + + '}', + }, + { + access: true, + type: 'input SendVoIPStartMessageData { ' + + '"""' + + 'If you want your B2CApp to handle incoming VoIP call, provide this argument. Otherwise provide all others' + + '"""' + + 'B2CAppContext: String, ' + + '"""' + + 'Unique value for each call session between panel and resident (means same for different devices also). ' + + 'Must be provided for correct work with multiple devices that use same voip call.' + + 'F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages' + + '"""' + + 'callId: String!, ' + + '"""' + + 'If "sip" was passed, mobile device will try to start native call. Info about other values will be added later' + + '"""' + + 'voipType: VoIPType, ' + + '"""' + + 'Address of sip server, which device should connect to' + + '"""' + + 'voipAddress: String, ' + + '"""' + + 'Login for connection to sip server' + + '"""' + + 'voipLogin: String, ' + + '"""' + + 'Password for connection to sip server' + + '"""' + + 'voipPassword: String, ' + + '"""' + + 'Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change' + + '"""' + + 'voipPanels: [VoIPPanel]' + + '"""' + + 'Stun server url' + + '"""' + + 'stun: String, ' + + '"""' + + 'Preferred codec (usually vp8)' + + '"""' + + 'codec: String ' + + '}', + }, + { + access: true, + type: 'input SendVoIPStartMessageInput { ' + + 'dv: Int!, ' + + 'sender: SenderFieldInput!, ' + + 'app: B2CAppWhereUniqueInput!, ' + + '"""' + + 'Should be "addressKey" of B2CAppProperty / Property for which you want to send message' + + '"""' + + 'addressKey: String!, ' + + '"""' + + 'Name of unit, same as in Property map' + + '"""' + + 'unitName: String!, ' + + '"""' + + 'Type of unit, same as in Property map' + + '"""' + + 'unitType: AllowedVoIPMessageUnitType!, ' + + 'data: SendVoIPStartMessageData! ' + + '}', + }, + { + access: true, + type: 'type SendVoIPStartMessageOutput { ' + + '"""' + + 'Count of all Organization Contacts, which we possibly could\'ve sent messages to' + + '"""' + + 'verifiedContactsCount: Int, ' + + '"""' + + 'Count of Messages that will be sent, one for each verified Resident' + + '"""' + + 'createdMessagesCount: Int,' + + '"""' + + 'Count of Messages which was not created due to some internal error' + + '"""' + + 'erroredMessagesCount: Int' + + '}', + }, + { + access: true, + type: `enum AllowedVoIPMessageUnitType { ${ALLOWED_UNIT_TYPES.join('\n')} }`, + }, + ], + + mutations: [ + { + access: access.canSendVoIPStartMessage, + schema: 'sendVoIPStartMessage(data: SendVoIPStartMessageInput!): SendVoIPStartMessageOutput', + doc: { + summary: 'Mutation sends VOIP_INCOMING_CALL Messages to each verified resident on address + unit. Also caches calls, so mobile app can properly react to cancel calls. ' + + 'You can either provide all data.* arguments so mobile app will use it\'s own app to answer call, or provide just B2CAppContext + callId to use your B2CApp\'s calling app', + errors: omit(ERRORS, 'DV_VERSION_MISMATCH', 'WRONG_SENDER_FORMAT'), + }, + resolver: async (parent, args, context) => { + const { data: argsData } = args + const { dv, sender, app, addressKey, unitName, unitType, data } = argsData + + checkDvAndSender(argsData, ERRORS.DV_VERSION_MISMATCH, ERRORS.WRONG_SENDER_FORMAT, context) + + const logInfoStats = { + step: 'init', + addressKey, + unitName, + unitType, + verifiedContactsCount: 0, + residentsCount: 0, + verifiedResidentsCount: 0, + createdMessagesCount: 0, + erroredMessagesCount: 0, + createMessageErrors: [], + isStatusCached: false, + isPropertyFound: false, + isAppFound: false, + } + + // 1) Check B2CApp and B2CAppProperty + const b2cAppId = app.id + + const [b2cAppProperty] = await B2CAppProperty.getAll(context, { + app: { id: b2cAppId, deletedAt: null }, + addressKey: addressKey, + deletedAt: null, + }, 'id app { id name }', { first: 1 }) + + if (!b2cAppProperty) { + logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + throw new GQLError(ERRORS.PROPERTY_NOT_FOUND, context) + } + logInfoStats.isPropertyFound = true + + if (!b2cAppProperty.app) { + logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + throw new GQLError(ERRORS.APP_NOT_FOUND, context) + } + logInfoStats.isAppFound = true + + const b2cAppName = b2cAppProperty.app.name + + // 2) Find Users + const verifiedContactsOnUnit = await find('Contact', { + property: { addressKey: addressKey, deletedAt: null }, + unitName_i: unitName, + unitType: unitType, + isVerified: true, + deletedAt: null, + }) + logInfoStats.step = 'find verified contacts' + logInfoStats.verifiedContactsCount = verifiedContactsOnUnit.length + + if (!verifiedContactsOnUnit?.length) { + logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + return { + verifiedContactsCount: 0, + createdMessagesCount: 0, + erroredMessagesCount: 0, + } + } + + const residentsOnUnit = await find('Resident', { + unitName_i: unitName, + unitType: unitType, + deletedAt: null, + property: { id_in: [...new Set(verifiedContactsOnUnit.map(contact => contact.property))] }, + }) + logInfoStats.step = 'find residents' + logInfoStats.residentsCount = residentsOnUnit.length + + // NOTE(YEgorLu): doing same as Resident.isVerifiedByManagingCompany virtual field + const usersOfContacts = await find('User', { + phone_in: [...new Set(verifiedContactsOnUnit.map(contact => contact.phone))], + deletedAt: null, + type: RESIDENT, + isPhoneVerified: true, + }) + + const uniqueUserIdsOfContacts = new Set(usersOfContacts.map(user => user.id)) + const residentsWithVerifiedContactOnAddress = residentsOnUnit.filter(resident => uniqueUserIdsOfContacts.has(resident.user)) + logInfoStats.step = 'verify residents' + logInfoStats.verifiedResidentsCount = residentsWithVerifiedContactOnAddress.length + + if (!residentsWithVerifiedContactOnAddress?.length) { + logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + return { + verifiedContactsCount: verifiedContactsOnUnit.length, + createdMessagesCount: 0, + erroredMessagesCount: 0, + } + } + + const userIdToLocale = {} + usersOfContacts.forEach(user => userIdToLocale[user.id] = user.locale) + + // NOTE(YEgorLu): there should be maximum 1 Resident for user + address + unitName + unitType, but just in case lets deduplicate + const residentsGroupedByUser = residentsWithVerifiedContactOnAddress.reduce((groupedByUser, resident) => { + groupedByUser[resident.user] = resident + return groupedByUser + }, {}) + const verifiedResidentsWithUniqueUsers = Object.values(residentsGroupedByUser) + + let appSettings + + if (!DEBUG_APP_ENABLED || b2cAppId !== DEBUG_APP_ID) { + appSettings = await getByCondition('AppMessageSetting', { + b2cApp: { id: b2cAppId }, type: VOIP_INCOMING_CALL_MESSAGE_TYPE, deletedAt: null, + }) + } + + else { + appSettings = { ...DEBUG_APP_SETTINGS } + } + + // 3) Create Messages + + const searchKey = `${VOIP_INCOMING_CALL_MESSAGE_TYPE}-${b2cAppId}` + const ttl = CACHE_TTL[VOIP_INCOMING_CALL_MESSAGE_TYPE] || CACHE_TTL['DEFAULT'] + + try { + await redisGuard.checkCustomLimitCounters( + `${SERVICE_NAME}-${searchKey}`, + get(appSettings, 'notificationWindowSize') ?? ttl, + get(appSettings, 'numberOfNotificationInWindow') ?? DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, + context, + ) + } catch (err) { + logInfoStats.step = 'check limits' + logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats, errors: err }) + throw err + } + + const startingMessagesIdsByUserIds = {} + + /** @type {Array>} */ + const sendMessagePromises = verifiedResidentsWithUniqueUsers + // .filter(resident => !rateLimitsErrorsByUserIds[resident.user]) + .map(async (resident) => { + // NOTE(YEgorLu): as in domains/notification/constants/config for VOIP_INCOMING_CALL_MESSAGE_TYPE + const preparedDataArgs = { + B2CAppId: b2cAppId, + B2CAppContext: data.B2CAppContext, + B2CAppName: b2cAppName, + residentId: resident.id, + callId: data.callId, + voipType: data.voipType, + voipAddress: data.voipAddress, + voipLogin: data.voipLogin, + voipPassword: data.voipPassword, + voipDtfmCommand: data.voipPanels?.[0]?.dtfmCommand, + voipPanels: data.voipPanels, + stun: data.stun, + codec: data.codec, + } + + const requiredMetaData = get(MESSAGE_META[VOIP_INCOMING_CALL_MESSAGE_TYPE], 'data', {}) + const metaData = Object.fromEntries( + Object.keys(requiredMetaData).map((key) => [key, preparedDataArgs[key]]) + ) + + const messageAttrs = { + sender, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + to: { user: { id: resident.user } }, + meta: { + dv, + title: i18n('api.miniapp.sendVoIPStartMessage.pushData.title', { locale: userIdToLocale[resident.user] }), + body: i18n('api.miniapp.sendVoIPStartMessage.pushData.body', { locale: userIdToLocale[resident.user] }), + data: metaData, + }, + } + + const res = await sendMessage(context, messageAttrs) + if (res?.id) { + startingMessagesIdsByUserIds[resident.user] = res.id + } + return { resident, result: res } + }) + + // 4) Set status in redis + + logInfoStats.step = 'send messages' + const sendMessageResults = await Promise.allSettled(sendMessagePromises) + const sendMessageStats = sendMessageResults.map(promiseResult => { + if (promiseResult.status === 'rejected') { + logInfoStats.erroredMessagesCount++ + logInfoStats.createMessageErrors.push(promiseResult.reason) + return { error: promiseResult.reason } + } + const { resident, result } = promiseResult.value + if (result.isDuplicateMessage) { + logInfoStats.erroredMessagesCount++ + logInfoStats.createMessageErrors.push(`${resident.id} duplicate message`) + return { error: `${resident.id} duplicate message` } + } + if (result.status !== MESSAGE_SENDING_STATUS) { + logInfoStats.erroredMessagesCount++ + logInfoStats.createMessageErrors.push(`${resident.id} invalid status for some reason`) + return { error: `${resident.id} invalid status for some reason` } + } + logInfoStats.createdMessagesCount++ + return result + }) + + for (const messageStat of sendMessageStats) { + if (messageStat.error) { + logInfoStats.erroredMessagesCount++ + logInfoStats.createMessageErrors.push(messageStat.error) + continue + } + logInfoStats.createdMessagesCount++ + } + + if (sendMessageStats.some(stat => !stat.error)) { + logInfoStats.isStatusCached = await setCallStatus({ + b2cAppId, + callId: data.callId, + status: CALL_STATUS_START_SENT, + // NOTE(YEgorLu): we can use uniqKey for that: [pushType, b2cAppId, callId, userId, YYYY-MM-DD].join() + // but this would require to check current and previous day/period + // for now lets save it in session, usually we receive cancel message in less than 1 minute anyway + startingMessagesIdsByUserIds: startingMessagesIdsByUserIds, // check uniqKey + }) + } + + logInfoStats.step = 'result' + logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + + return { + verifiedContactsCount: verifiedContactsOnUnit.length, + createdMessagesCount: sendMessageStats.filter(stat => !stat.error).length, + erroredMessagesCount: sendMessageStats.filter(stat => !!stat.error).length, + } + }, + }, + ], + +}) + +module.exports = { + SendVoIPStartMessageService, + ERRORS, + CACHE_TTL, +} diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js new file mode 100644 index 00000000000..773401527e4 --- /dev/null +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js @@ -0,0 +1,515 @@ +const { faker } = require('@faker-js/faker') + +const { + makeLoggedInAdminClient, + makeClient, + expectToThrowAccessDeniedErrorToResult, + expectToThrowAuthenticationErrorToResult, + expectToThrowGQLErrorToResult, + expectToThrowGraphQLRequestError, +} = require('@open-condo/keystone/test.utils') + +const { createTestContact } = require('@condo/domains/contact/utils/testSchema') +const { + createTestB2CApp, + sendVoIPStartMessageByTestClient, + createTestB2CAppProperty, +} = require('@condo/domains/miniapp/utils/testSchema') +const { getCallStatus, CALL_STATUS_START_SENT } = require('@condo/domains/miniapp/utils/voip') +const { + VOIP_INCOMING_CALL_MESSAGE_TYPE, +} = require('@condo/domains/notification/constants/constants') +const { Message } = require('@condo/domains/notification/utils/testSchema') +const { FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE, UNIT_TYPES } = require('@condo/domains/property/constants/common') +const { makeClientWithResidentAccessAndProperty } = require('@condo/domains/property/utils/testSchema') +const { createTestResident } = require('@condo/domains/resident/utils/testSchema') +const { makeClientWithSupportUser, makeClientWithNewRegisteredAndLoggedInUser, + makeClientWithResidentUser, createTestPhone, +} = require('@condo/domains/user/utils/testSchema') + +const { ERRORS } = require('./SendVoIPStartMessageService') + +describe('SendVoIPStartMessageService', () => { + let admin + + beforeAll(async () => { + admin = await makeLoggedInAdminClient() + }) + + describe('Access', () => { + let b2cApp + let property + + beforeAll(async () => { + const [testB2CApp] = await createTestB2CApp(admin) + b2cApp = testB2CApp + + const { property: testProperty } = await makeClientWithResidentAccessAndProperty() + property = testProperty + await createTestB2CAppProperty(admin, b2cApp, { address: testProperty.address, addressMeta: testProperty.addressMeta }) + }) + + const TEST_CASES = [ + { name: 'admin can', getClient: () => admin, expectError: null }, + { name: 'support can\'t', getClient: () => makeClientWithSupportUser(), expectError: expectToThrowAccessDeniedErrorToResult }, + { name: 'user can\'t', getClient: () => makeClientWithNewRegisteredAndLoggedInUser(), expectError: expectToThrowAccessDeniedErrorToResult }, + { name: 'anonymous can\'t', getClient: () => makeClient(), expectError: expectToThrowAuthenticationErrorToResult }, + // TODO(YEgorLu): Add b2c service user with / without access right set tests after adding it to accesses + ] + + test.each(TEST_CASES)('$name', async ({ getClient, expectError }) => { + const client = await getClient() + const mutate = () => sendVoIPStartMessageByTestClient(client, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: faker.random.alphaNumeric(3), + unitType: FLAT_UNIT_TYPE, + data: { + callId: faker.datatype.uuid(), + }, + }) + if (expectError) { + await expectError(async () => await mutate()) + } else { + const [result] = await mutate() + expect(result).toBeDefined() + expect(result.verifiedContactsCount).toBe(0) + expect(result.createdMessagesCount).toBe(0) + expect(result.erroredMessagesCount).toBe(0) + } + }) + }) + + describe('Validation', () => { + + test('should throw error if no app with provided id', async () => { + await expectToThrowGQLErrorToResult(async () => { + await sendVoIPStartMessageByTestClient(admin, { + app: { id: faker.datatype.uuid() }, + addressKey: faker.datatype.uuid(), + unitName: faker.random.alphaNumeric(3), + unitType: FLAT_UNIT_TYPE, + data: { + callId: faker.datatype.uuid(), + }, + }) + }, ERRORS.PROPERTY_NOT_FOUND) + }) + + test('should throw error if no b2cappproperty with provided addressKey', async () => { + const [b2cApp] = await createTestB2CApp(admin) + + await expectToThrowGQLErrorToResult(async () => { + await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: faker.datatype.uuid(), + unitName: faker.random.alphaNumeric(3), + unitType: FLAT_UNIT_TYPE, + data: { + callId: faker.datatype.uuid(), + }, + }) + }, ERRORS.PROPERTY_NOT_FOUND) + }) + + }) + + describe('Logic', () => { + test('successfully sends VoIP start message when all conditions are met', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + const verifiedContactsCount = 5 + const verifiedResidentsCount = 3 + const notVerifiedResidentsCount = verifiedContactsCount - verifiedResidentsCount + + const prepareDataPromises = [] + + for (let i = 0; i < verifiedContactsCount; i++) { + prepareDataPromises.push((async (admin) => { + const phone = createTestPhone() + const userClient = await makeClientWithResidentUser({}, { phone }) + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: true, + phone: phone, + }) + const needToCreateVerifiedResident = i < verifiedResidentsCount + if (needToCreateVerifiedResident) { + await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + } + })(admin)) + } + for (let i = 0; i < notVerifiedResidentsCount; i++) { + prepareDataPromises.push((async (admin) => { + const userClient = await makeClientWithResidentUser() + await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + })(admin)) + } + await Promise.all(prepareDataPromises) + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + data: { + callId: faker.datatype.uuid(), + }, + }) + + expect(result.verifiedContactsCount).toBe(verifiedContactsCount) + expect(result.createdMessagesCount).toBe(verifiedResidentsCount) + expect(result.erroredMessagesCount).toBe(0) + }) + + // NOTE(YEgorLu): in case someone changes notification constants for message type + test('created messages do not exclude provided data', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + const residentsCount = 3 + const prepareDataPromises = [] + const userIds = [] + const residentsIds = [] + + for (let i = 0; i < residentsCount; i++) { + prepareDataPromises.push((async (admin) => { + const phone = createTestPhone() + const userClient = await makeClientWithResidentUser({}, { phone }) + userIds.push(userClient.user.id) + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: true, + phone: phone, + }) + const [resident] = await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + residentsIds.push(resident.id) + })(admin)) + } + await Promise.all(prepareDataPromises) + + const dataAttrs = { + B2CAppContext: faker.random.alphaNumeric(10), + callId: faker.datatype.uuid(), + voipType: 'sip', + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [ + { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + ], + stun: faker.internet.ip(), + codec: 'vp8', + } + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + data: dataAttrs, + }) + expect(result.verifiedContactsCount).toBe(residentsCount) + expect(result.createdMessagesCount).toBe(residentsCount) + expect(result.erroredMessagesCount).toBe(0) + + const createdMessages = await Message.getAll(admin, { + user: { id_in: userIds }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: residentsCount }) + + expect(createdMessages).toHaveLength(residentsCount) + expect([...new Set(createdMessages.map(msg => msg.user))]).toHaveLength(residentsCount) + expect(createdMessages).toEqual( + expect.arrayContaining(residentsIds.map(residentId => { + return expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + B2CAppId: b2cApp.id, + B2CAppContext: dataAttrs.B2CAppContext, + B2CAppName: b2cApp.name, + residentId: residentId, + callId: dataAttrs.callId, + voipType: dataAttrs.voipType, + voipAddress: dataAttrs.voipAddress, + voipLogin: dataAttrs.voipLogin, + voipPassword: dataAttrs.voipPassword, + voipDtfmCommand: dataAttrs.voipPanels[0].dtfmCommand, + //voipPanels: dataAttrs.voipPanels, needs to be added in notification constants + stun: dataAttrs.stun, + codec: dataAttrs.codec, + }), + }), + }) + })) + ) + }) + + test('returns zeroish stats when no verified contacts found on unit', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: false, + }) + const userClient = await makeClientWithResidentUser() + await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + data: { + callId: faker.datatype.uuid(), + }, + }) + + expect(result.verifiedContactsCount).toBe(0) + expect(result.createdMessagesCount).toBe(0) + expect(result.erroredMessagesCount).toBe(0) + }) + + test('returns zero message attempts when no residents found for verified contacts', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: true, + }) + const userClient = await makeClientWithResidentUser() + await createTestResident(admin, userClient.user, property, { + unitName: faker.random.alphaNumeric(3), + unitType: unitType, + }) + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + data: { + callId: faker.datatype.uuid(), + }, + }) + + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(0) + expect(result.erroredMessagesCount).toBe(0) + }) + + const ALLOWED_UNIT_TYPES = [FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE] + + test(`Includes only ${ALLOWED_UNIT_TYPES.join(' / ')} units`, async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + + for (const possibleUnitType of UNIT_TYPES) { + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: possibleUnitType, + isVerified: true, + }) + } + + const mutate = (unitType) => sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + data: { + callId: faker.datatype.uuid(), + }, + }) + + for (const allowedUnitType of ALLOWED_UNIT_TYPES) { + const [result] = await mutate(allowedUnitType) + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(0) + expect(result.erroredMessagesCount).toBe(0) + } + + const forbiddenUnitTypes = UNIT_TYPES.filter(unitType => !ALLOWED_UNIT_TYPES.includes(unitType)) + expect(forbiddenUnitTypes).toHaveLength(3) + + for (const forbiddenUnitType of forbiddenUnitTypes) { + await expectToThrowGraphQLRequestError( + async () => await mutate(forbiddenUnitType), + `Variable "$data" got invalid value "${forbiddenUnitType}" at "data.unitType"; Value "${forbiddenUnitType}" does not exist in "AllowedVoIPMessageUnitType" enum.` + ) + } + }) + + describe('Cache', () => { + test('Saves call status in cache', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + const residentsCount = 3 + const prepareDataPromises = [] + + for (let i = 0; i < residentsCount; i++) { + prepareDataPromises.push((async (admin) => { + const phone = createTestPhone() + const userClient = await makeClientWithResidentUser({}, { phone }) + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: true, + phone: phone, + }) + await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + })(admin)) + } + await Promise.all(prepareDataPromises) + + const callId = faker.datatype.uuid() + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + data: { callId }, + }) + expect(result.verifiedContactsCount).toBe(residentsCount) + expect(result.createdMessagesCount).toBe(residentsCount) + expect(result.erroredMessagesCount).toBe(0) + + const cache = await getCallStatus({ b2cAppId: b2cApp.id, callId }) + expect(cache).not.toBe(null) + expect(cache.status).toBe(CALL_STATUS_START_SENT) + }) + + test('Saves User.id to Message.id binding', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + const residentsCount = 3 + const prepareDataPromises = [] + const userIds = [] + + for (let i = 0; i < residentsCount; i++) { + prepareDataPromises.push((async (admin) => { + const phone = createTestPhone() + const userClient = await makeClientWithResidentUser({}, { phone }) + userIds.push(userClient.user.id) + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: true, + phone: phone, + }) + await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + })(admin)) + } + await Promise.all(prepareDataPromises) + + const callId = faker.datatype.uuid() + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + data: { callId }, + }) + expect(result.verifiedContactsCount).toBe(residentsCount) + expect(result.createdMessagesCount).toBe(residentsCount) + expect(result.erroredMessagesCount).toBe(0) + + const createdMessages = await Message.getAll(admin, { + user: { id_in: userIds }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: residentsCount }) + expect(createdMessages).toHaveLength(residentsCount) + const expectedUserIdToMessageId = Object.fromEntries(createdMessages.map(message => ([message.user.id, message.id]))) + + const cache = await getCallStatus({ b2cAppId: b2cApp.id, callId }) + expect(cache).not.toBe(null) + expect(cache.startingMessagesIdsByUserIds).toEqual(expectedUserIdToMessageId) + }) + + test('Does not save status if have no users to send push', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + const unitName = faker.random.alphaNumeric(3) + await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: FLAT_UNIT_TYPE, + isVerified: true, + }) + const callId = faker.datatype.uuid() + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: FLAT_UNIT_TYPE, + data: { + callId: callId, + }, + }) + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(0) + expect(result.erroredMessagesCount).toBe(0) + + const cache = await getCallStatus({ b2cAppId: b2cApp.id, callId }) + expect(cache).toBe(null) + }) + }) + }) +}) \ No newline at end of file diff --git a/apps/condo/domains/miniapp/schema/index.js b/apps/condo/domains/miniapp/schema/index.js index 59d58f54c02..aec0230adbc 100644 --- a/apps/condo/domains/miniapp/schema/index.js +++ b/apps/condo/domains/miniapp/schema/index.js @@ -25,6 +25,8 @@ const { CustomValue } = require('./CustomValue') const { MessageAppBlackList } = require('./MessageAppBlackList') const { SendB2BAppPushMessageService } = require('./SendB2BAppPushMessageService') const { SendB2CAppPushMessageService } = require('./SendB2CAppPushMessageService') +const { SendVoIPStartMessageService } = require('./SendVoIPStartMessageService') + /* AUTOGENERATE MARKER */ module.exports = { @@ -50,5 +52,6 @@ module.exports = { CustomValue, B2BAppPosIntegrationConfig, B2CAppAccessRightSet, + SendVoIPStartMessageService, /* AUTOGENERATE MARKER */ } diff --git a/apps/condo/domains/miniapp/utils/testSchema/index.js b/apps/condo/domains/miniapp/utils/testSchema/index.js index 35d495127bd..5c2b303190b 100644 --- a/apps/condo/domains/miniapp/utils/testSchema/index.js +++ b/apps/condo/domains/miniapp/utils/testSchema/index.js @@ -18,6 +18,7 @@ const { const { ALL_MINI_APPS_QUERY, SEND_B2C_APP_PUSH_MESSAGE_MUTATION, + SEND_VOIP_START_MESSAGE_MUTATION, B2BApp: B2BAppGQL, B2BAppContext: B2BAppContextGQL, B2BAppAccessRight: B2BAppAccessRightGQL, @@ -389,6 +390,25 @@ async function sendB2CAppPushMessageByTestClient (client, extraAttrs = {}) { return [data.result, attrs] } +async function sendVoIPStartMessageByTestClient (client, extraAttrs = {}) { + if (!client) throw new Error('no client') + + const sender = { dv: 1, fingerprint: faker.random.alphaNumeric(8) } + const attrs = { + dv: 1, + sender, + ...extraAttrs, + data: { + ...get(extraAttrs, 'data', {}), + }, + } + const { data, errors } = await client.mutate(SEND_VOIP_START_MESSAGE_MUTATION, { data: attrs }) + + throwIfError(data, errors, { query: SEND_VOIP_START_MESSAGE_MUTATION, variables: { data: attrs } }) + + return [data.result, attrs] +} + async function createTestMessageAppBlackList (client, extraAttrs = {}) { if (!client) throw new Error('no client') @@ -835,5 +855,6 @@ module.exports = { AppMessageSetting, createTestAppMessageSetting, updateTestAppMessageSetting, B2BAppPosIntegrationConfig, createTestB2BAppPosIntegrationConfig, updateTestB2BAppPosIntegrationConfig, B2CAppAccessRightSet, createTestB2CAppAccessRightSet, updateTestB2CAppAccessRightSet, + sendVoIPStartMessageByTestClient, /* AUTOGENERATE MARKER */ } diff --git a/apps/condo/domains/miniapp/utils/voip.js b/apps/condo/domains/miniapp/utils/voip.js new file mode 100644 index 00000000000..503dda38e26 --- /dev/null +++ b/apps/condo/domains/miniapp/utils/voip.js @@ -0,0 +1,59 @@ +const { getKVClient } = require('@open-condo/keystone/kv') + +const KEY_PREFIX = 'voipCallStatus' +const kv = getKVClient(KEY_PREFIX) +const CALL_STATUS_TTL_IN_SECONDS = 60 * 3 + +const CALL_STATUS_START_SENT = 'CALL_STATUS_START_SENT' +const CALL_STATUS_CANCEL_SENT = 'CALL_STATUS_CANCEL_SENT' +const CALL_STATUSES = { + CALL_STATUS_START_SENT: CALL_STATUS_START_SENT, + CALL_STATUS_CANCEL_SENT: CALL_STATUS_CANCEL_SENT, +} + +function validateCallId (callId) { + return typeof callId === 'string' + && callId.length > 0 + && callId.length < 300 +} + +function normalizeCallId (callId) { + return callId + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001F\s]/g, '') +} + +function buildKey (b2cAppId, callId) { + return [KEY_PREFIX, b2cAppId, Buffer.from(callId).toString('base64')].join(':') +} + +// NOTE(YEgorLu): startingMessagesIdsByUserIds present for older mobile apps compatibility, as they use this to cancel calls. +// Will be removed someday in the future. Should be okay to save it only for few minutes +async function setCallStatus ({ b2cAppId, callId, status, startingMessagesIdsByUserIds }) { + if (!validateCallId(callId)) return false + return kv.set( + buildKey(b2cAppId, normalizeCallId(callId)), + JSON.stringify({ status, startingMessagesIdsByUserIds }), + 'EX', + CALL_STATUS_TTL_IN_SECONDS, + ) +} + +async function getCallStatus ({ b2cAppId, callId }) { + if (!validateCallId(callId)) return null + const res = await kv.get(buildKey(b2cAppId, normalizeCallId(callId))) + try { + return JSON.parse(res) + } catch { + return null + } +} + +module.exports = { + setCallStatus, + getCallStatus, + + CALL_STATUSES, + CALL_STATUS_START_SENT, + CALL_STATUS_CANCEL_SENT, +} \ No newline at end of file diff --git a/apps/condo/lang/en/en.json b/apps/condo/lang/en/en.json index 71af4704005..7d649411692 100644 --- a/apps/condo/lang/en/en.json +++ b/apps/condo/lang/en/en.json @@ -483,6 +483,12 @@ "api.miniapp.sendB2CAppPushMessage.RESIDENT_NOT_FOUND": "Unable to find resident by provided id.", "api.miniapp.sendB2CAppPushMessage.USER_NOT_FOUND": "Unable to find user by provided id.", "api.miniapp.sendB2CAppPushMessage.WRONG_SENDER_FORMAT": "Invalid format of \"sender\" field value. {details}", + "api.miniapp.sendVoIPStartMessage.PROPERTY_NOT_FOUND": "Building not found for the provided \"addressKey\"", + "api.miniapp.sendVoIPStartMessage.APP_NOT_FOUND": "Application not found for the provided \"id\"", + "api.miniapp.sendVoIPStartMessage.DV_VERSION_MISMATCH": "Invalid data version value", + "api.miniapp.sendVoIPStartMessage.WRONG_SENDER_FORMAT": "Invalid format for the \"sender\" field", + "api.miniapp.sendVoIPStartMessage.pushData.title": "Intercom call", + "api.miniapp.sendVoIPStartMessage.pushData.body": "Intercom call", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "Response from NewsSharing miniapp was successful, but the data format was incorrect. Please consult with miniapp developer", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "Could not get a successful response from NewsSharing miniapp. Please check the network and url configuration", "api.news.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Provided b2bAppContext.app is not a NewsSharing miniapp", diff --git a/apps/condo/lang/es/es.json b/apps/condo/lang/es/es.json index 010711f283d..8210bc80b5b 100644 --- a/apps/condo/lang/es/es.json +++ b/apps/condo/lang/es/es.json @@ -483,6 +483,12 @@ "api.miniapp.sendB2CAppPushMessage.RESIDENT_NOT_FOUND": "No se ha encontrado el vecino con el id introducido", "api.miniapp.sendB2CAppPushMessage.USER_NOT_FOUND": "No se ha encontrado el usuario con el id introducido", "api.miniapp.sendB2CAppPushMessage.WRONG_SENDER_FORMAT": "Formato del campo \"sender\" no válido", + "api.miniapp.sendVoIPStartMessage.PROPERTY_NOT_FOUND": "No se encontró el edificio para el \"addressKey\" proporcionado", + "api.miniapp.sendVoIPStartMessage.APP_NOT_FOUND": "No se encontró la aplicación para el \"id\" proporcionado", + "api.miniapp.sendVoIPStartMessage.DV_VERSION_MISMATCH": "Valor de versión de datos no válido", + "api.miniapp.sendVoIPStartMessage.WRONG_SENDER_FORMAT": "Formato no válido para el campo \"sender\"", + "api.miniapp.sendVoIPStartMessage.pushData.title": "Llamada del interfono", + "api.miniapp.sendVoIPStartMessage.pushData.body": "Llamada del interfono", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "La solicitud a la miniapp ha devuelto datos incorrectos", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "La solicitud a la miniapp se completó con un error.", "api.news.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Esta miniapp no admite la funcionalidad de envío de noticias", diff --git a/apps/condo/lang/ru/ru.json b/apps/condo/lang/ru/ru.json index 6758c69beff..00472d33ef6 100644 --- a/apps/condo/lang/ru/ru.json +++ b/apps/condo/lang/ru/ru.json @@ -483,6 +483,12 @@ "api.miniapp.sendB2CAppPushMessage.RESIDENT_NOT_FOUND": "Не удалось найти жителя по переданному id", "api.miniapp.sendB2CAppPushMessage.USER_NOT_FOUND": "Не удалось найти пользователя по переданному id", "api.miniapp.sendB2CAppPushMessage.WRONG_SENDER_FORMAT": "Неверный формат поля \"sender\"", + "api.miniapp.sendVoIPStartMessage.PROPERTY_NOT_FOUND": "Не удалось найти здание по переданному \"addressKey\"", + "api.miniapp.sendVoIPStartMessage.APP_NOT_FOUND": "Не удалось найти приложение по переданному \"id\"", + "api.miniapp.sendVoIPStartMessage.DV_VERSION_MISMATCH": "Неверное значение версии данных", + "api.miniapp.sendVoIPStartMessage.WRONG_SENDER_FORMAT": "Неверный формат поля \"sender\"", + "api.miniapp.sendVoIPStartMessage.pushData.title": "Звонок домофона", + "api.miniapp.sendVoIPStartMessage.pushData.body": "Звонок домофона", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "Запрос в миниапп вернул некорректные данные", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "Запрос в миниапп завершился с ошибкой", "api.news.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Этот миниапп не поддерживает функциональность отправки новостей", diff --git a/apps/condo/schema.graphql b/apps/condo/schema.graphql index 1bf3db16c3f..754fbdbbfe4 100644 --- a/apps/condo/schema.graphql +++ b/apps/condo/schema.graphql @@ -93722,6 +93722,92 @@ type SendB2BAppPushMessageOutput { id: String! } +enum VoIPType { + """Makes mobile app use it's call app instead of B2CApp's""" + sip +} + +input VoIPPanel { + """Dtfm command for panel""" + dtfmCommand: String! + + """Name of a panel to be displayed""" + name: String! +} + +input SendVoIPStartMessageData { + """ + If you want your B2CApp to handle incoming VoIP call, provide this argument. Otherwise provide all others + """ + B2CAppContext: String + + """ + Unique value for each call session between panel and resident (means same for different devices also). Must be provided for correct work with multiple devices that use same voip call.F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages + """ + callId: String! + + """ + If "sip" was passed, mobile device will try to start native call. Info about other values will be added later + """ + voipType: VoIPType + + """Address of sip server, which device should connect to""" + voipAddress: String + + """Login for connection to sip server""" + voipLogin: String + + """Password for connection to sip server""" + voipPassword: String + + """ + Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change + """ + voipPanels: [VoIPPanel] + + """Stun server url""" + stun: String + + """Preferred codec (usually vp8)""" + codec: String +} + +input SendVoIPStartMessageInput { + dv: Int! + sender: SenderFieldInput! + app: B2CAppWhereUniqueInput! + + """ + Should be "addressKey" of B2CAppProperty / Property for which you want to send message + """ + addressKey: String! + + """Name of unit, same as in Property map""" + unitName: String! + + """Type of unit, same as in Property map""" + unitType: AllowedVoIPMessageUnitType! + data: SendVoIPStartMessageData! +} + +type SendVoIPStartMessageOutput { + """ + Count of all Organization Contacts, which we possibly could've sent messages to + """ + verifiedContactsCount: Int + + """Count of Messages that will be sent, one for each verified Resident""" + createdMessagesCount: Int + + """Count of Messages which was not created due to some internal error""" + erroredMessagesCount: Int +} + +enum AllowedVoIPMessageUnitType { + flat + apartment +} + type unauthenticateUserOutput { """ `true` when unauthentication succeeds. @@ -108079,6 +108165,29 @@ type Mutation { """ sendB2BAppPushMessage(data: SendB2BAppPushMessageInput!): SendB2BAppPushMessageOutput + """ + Mutation sends VOIP_INCOMING_CALL Messages to each verified resident on address + unit. Also caches calls, so mobile app can properly react to cancel calls. You can either provide all data.* arguments so mobile app will use it's own app to answer call, or provide just B2CAppContext + callId to use your B2CApp's calling app + + + + **Errors** + + Following objects will be presented in `extensions` property of thrown error + + `{ + "code": "BAD_USER_INPUT", + "type": "PROPERTY_NOT_FOUND", + "message": "Unable to find Property or B2CAppProperty by provided addressKey" + }` + + `{ + "code": "BAD_USER_INPUT", + "type": "APP_NOT_FOUND", + "message": "Unable to find B2CApp." + }` + """ + sendVoIPStartMessage(data: SendVoIPStartMessageInput!): SendVoIPStartMessageOutput + """ Authenticate and generate a token for a User with the Password Authentication Strategy. """ authenticateUserWithPassword(email: String, password: String): authenticateUserOutput diff --git a/apps/condo/schema.ts b/apps/condo/schema.ts index 209ce7514b7..003dd1c6f40 100644 --- a/apps/condo/schema.ts +++ b/apps/condo/schema.ts @@ -1987,6 +1987,11 @@ export type AllMiniAppsWhereInput = { connected?: InputMaybe; }; +export enum AllowedVoIpMessageUnitType { + Apartment = 'apartment', + Flat = 'flat' +} + export type AmountDistributionField = { __typename?: 'AmountDistributionField'; amount: Scalars['String']['output']; @@ -50871,6 +50876,28 @@ export type Mutation = { * }` */ sendOrganizationEmployeeRequest?: Maybe; + /** + * Mutation sends VOIP_INCOMING_CALL Messages to each verified resident on address + unit. Also caches calls, so mobile app can properly react to cancel calls. You can either provide all data.* arguments so mobile app will use it's own app to answer call, or provide just B2CAppContext + callId to use your B2CApp's calling app + * + * + * + * **Errors** + * + * Following objects will be presented in `extensions` property of thrown error + * + * `{ + * "code": "BAD_USER_INPUT", + * "type": "PROPERTY_NOT_FOUND", + * "message": "Unable to find Property or B2CAppProperty by provided addressKey" + * }` + * + * `{ + * "code": "BAD_USER_INPUT", + * "type": "APP_NOT_FOUND", + * "message": "Unable to find B2CApp." + * }` + */ + sendVoIPStartMessage?: Maybe; setMessageStatus?: Maybe; setPaymentPosReceiptUrl?: Maybe; shareTicket?: Maybe; @@ -58717,6 +58744,11 @@ export type MutationSendOrganizationEmployeeRequestArgs = { }; +export type MutationSendVoIpStartMessageArgs = { + data: SendVoIpStartMessageInput; +}; + + export type MutationSetMessageStatusArgs = { data: SetMessageStatusInput; }; @@ -89799,6 +89831,50 @@ export type SendOrganizationEmployeeRequestInput = { sender: SenderFieldInput; }; +export type SendVoIpStartMessageData = { + /** If you want your B2CApp to handle incoming VoIP call, provide this argument. Otherwise provide all others */ + B2CAppContext?: InputMaybe; + /** Unique value for each call session between panel and resident (means same for different devices also). Must be provided for correct work with multiple devices that use same voip call.F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages */ + callId: Scalars['String']['input']; + /** Preferred codec (usually vp8) */ + codec?: InputMaybe; + /** Stun server url */ + stun?: InputMaybe; + /** Address of sip server, which device should connect to */ + voipAddress?: InputMaybe; + /** Login for connection to sip server */ + voipLogin?: InputMaybe; + /** Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change */ + voipPanels?: InputMaybe>>; + /** Password for connection to sip server */ + voipPassword?: InputMaybe; + /** If "sip" was passed, mobile device will try to start native call. Info about other values will be added later */ + voipType?: InputMaybe; +}; + +export type SendVoIpStartMessageInput = { + /** Should be "addressKey" of B2CAppProperty / Property for which you want to send message */ + addressKey: Scalars['String']['input']; + app: B2CAppWhereUniqueInput; + data: SendVoIpStartMessageData; + dv: Scalars['Int']['input']; + sender: SenderFieldInput; + /** Name of unit, same as in Property map */ + unitName: Scalars['String']['input']; + /** Type of unit, same as in Property map */ + unitType: AllowedVoIpMessageUnitType; +}; + +export type SendVoIpStartMessageOutput = { + __typename?: 'SendVoIPStartMessageOutput'; + /** Count of Messages that will be sent, one for each verified Resident */ + createdMessagesCount?: Maybe; + /** Count of Messages which was not created due to some internal error */ + erroredMessagesCount?: Maybe; + /** Count of all Organization Contacts, which we possibly could've sent messages to */ + verifiedContactsCount?: Maybe; +}; + export type SenderField = { __typename?: 'SenderField'; dv: Scalars['Int']['output']; @@ -117562,6 +117638,18 @@ export enum VillageMapType { Village = 'village' } +export type VoIpPanel = { + /** Dtfm command for panel */ + dtfmCommand: Scalars['String']['input']; + /** Name of a panel to be displayed */ + name: Scalars['String']['input']; +}; + +export enum VoIpType { + /** Makes mobile app use it's call app instead of B2CApp's */ + Sip = 'sip' +} + /** Webhooks are a way that the APP can send automated web callback with some messages to other apps or system to inform them about any updates. How does it work: 1. When objects are created or changed, we make requests to the GraphQL API to get data on behalf of the specified user; 2. Then we send the data to remote url. Webhook model contains basic configuration of integration, such as external server url, name, encryption parameters and so on. */ export type Webhook = { __typename?: 'Webhook'; From dc4f8b5211fe4056914cd537d8432787aab7ecd6 Mon Sep 17 00:00:00 2001 From: YEgorLu Date: Tue, 17 Feb 2026 15:03:27 +0500 Subject: [PATCH 2/6] feat(condo): DOMA-12905 differentiate native and b2c calls in schema --- apps/condo/domains/miniapp/constants.js | 3 + .../schema/SendVoIPStartMessageService.js | 253 ++++++++++-------- .../SendVoIPStartMessageService.test.js | 93 ++++--- .../domains/miniapp/utils/testSchema/index.js | 3 - apps/condo/schema.graphql | 73 +++-- apps/condo/schema.ts | 56 ++-- 6 files changed, 293 insertions(+), 188 deletions(-) diff --git a/apps/condo/domains/miniapp/constants.js b/apps/condo/domains/miniapp/constants.js index df8a9043bf1..e346a03ba54 100644 --- a/apps/condo/domains/miniapp/constants.js +++ b/apps/condo/domains/miniapp/constants.js @@ -85,6 +85,7 @@ const ACCESS_TOKEN_WRONG_RIGHT_SET_TYPE = 'ACCESS_TOKEN_WRONG_RIGHT_SET_TYPE' const ACCESS_TOKEN_CONTEXT_DOES_NOT_MATCH_RIGHT_SET = 'ACCESS_TOKEN_CONTEXT_DOES_NOT_MATCH_RIGHT_SET' const ACCESS_TOKEN_SERVICE_USER_DOES_NOT_MATCH_CONTEXT = 'ACCESS_TOKEN_SERVICE_USER_DOES_NOT_MATCH_CONTEXT' const ACCESS_TOKEN_NEGATIVE_TTL = 'ACCESS_TOKEN_NEGATIVE_TTL' +const CALL_DATA_NOT_PROVIDED_ERROR = 'CALL_DATA_NOT_PROVIDED' const MAX_PERMISSION_NAME_LENGTH = 50 const MIN_PERMISSION_NAME_LENGTH = 1 @@ -207,4 +208,6 @@ module.exports = { DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, DEFAULT_NOTIFICATION_WINDOW_DURATION_IN_SECONDS, + + CALL_DATA_NOT_PROVIDED_ERROR, } \ No newline at end of file diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js index d8dcabf1ac1..7b985e32582 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js @@ -15,6 +15,7 @@ const { APP_NOT_FOUND_ERROR, DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, DEFAULT_NOTIFICATION_WINDOW_DURATION_IN_SECONDS, + CALL_DATA_NOT_PROVIDED_ERROR, } = require('@condo/domains/miniapp/constants') const { B2CAppProperty } = require('@condo/domains/miniapp/utils/serverSchema') const { setCallStatus, CALL_STATUS_START_SENT } = require('@condo/domains/miniapp/utils/voip') @@ -63,6 +64,13 @@ const ERRORS = { message: 'Unable to find B2CApp.', messageForUser: `api.miniapp.${SERVICE_NAME}.APP_NOT_FOUND`, }, + CALL_DATA_NOT_PROVIDED: { + mutation: SERVICE_NAME, + variable: ['data, callData'], + type: CALL_DATA_NOT_PROVIDED_ERROR, + code: BAD_USER_INPUT, + message: '"b2cAppCallData" or "nativeCallData" or both should be provided', + }, DV_VERSION_MISMATCH: { ...COMMON_ERRORS.DV_VERSION_MISMATCH, mutation: SERVICE_NAME, @@ -73,6 +81,14 @@ const ERRORS = { }, } +/* + """ + If "sip" was passed, mobile device will try to start native call. Info about other values will be added later + """ + voipType: VoIPType, +*/ +const MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY = 'sip' // without this constant mobile app will not try to make native call + const logInfo = ({ b2cAppId, callId, stats, errors }) => { logger.info({ msg: `${SERVICE_NAME} stats`, entityName: 'B2CApp', entityId: b2cAppId, data: { callId, stats }, err: errors }) } @@ -81,106 +97,112 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer types: [ { access: true, - type: 'enum VoIPType {' + - '"""' + - 'Makes mobile app use it\'s call app instead of B2CApp\'s' + - '"""' + - 'sip' + - '}', + type: `input VoIPPanel { + """ + Dtfm command for panel + """ + dtfmCommand: String! + """ + Name of a panel to be displayed + """ + name: String! + }`, + }, + { + access: true, + type: `input SendVoIPStartMessageDataForCallHandlingByB2CApp { + """ + Data that will be provided to B2CApp. May be stringified JSON + """ + B2CAppContext: String!, + }`, }, { access: true, - type: 'input VoIPPanel {' + - '"""' + - 'Dtfm command for panel' + - '"""' + - 'dtfmCommand: String!' + - '"""' + - 'Name of a panel to be displayed' + - '"""' + - 'name: String!' + - '}', + type: `input SendVoIPStartMessageDataForCallHandlingByNative { + """ + Address of sip server, which device should connect to + """ + voipAddress: String!, + """ + Login for connection to sip server + """ + voipLogin: String!, + """ + Password for connection to sip server + """ + voipPassword: String!, + """ + Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change + """ + voipPanels: [VoIPPanel!]! + """ + Stun server urls. Are used to determine device public ip for media streams + """ + stunServers: [String!], + """ + Preferred codec (usually vp8) + """ + codec: String + }`, }, { access: true, - type: 'input SendVoIPStartMessageData { ' + - '"""' + - 'If you want your B2CApp to handle incoming VoIP call, provide this argument. Otherwise provide all others' + - '"""' + - 'B2CAppContext: String, ' + - '"""' + - 'Unique value for each call session between panel and resident (means same for different devices also). ' + - 'Must be provided for correct work with multiple devices that use same voip call.' + - 'F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages' + - '"""' + - 'callId: String!, ' + - '"""' + - 'If "sip" was passed, mobile device will try to start native call. Info about other values will be added later' + - '"""' + - 'voipType: VoIPType, ' + - '"""' + - 'Address of sip server, which device should connect to' + - '"""' + - 'voipAddress: String, ' + - '"""' + - 'Login for connection to sip server' + - '"""' + - 'voipLogin: String, ' + - '"""' + - 'Password for connection to sip server' + - '"""' + - 'voipPassword: String, ' + - '"""' + - 'Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change' + - '"""' + - 'voipPanels: [VoIPPanel]' + - '"""' + - 'Stun server url' + - '"""' + - 'stun: String, ' + - '"""' + - 'Preferred codec (usually vp8)' + - '"""' + - 'codec: String ' + - '}', + type: `input SendVoIPStartMessageData { + """ + Unique value for each call session between panel and resident (means same for different devices also). + Must be provided for correct work with multiple devices that use same voip call. + F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages + """ + callId: String!, + """ + If you want your B2CApp to handle incoming VoIP call, provide this argument. + """ + b2cAppCallData: SendVoIPStartMessageDataForCallHandlingByB2CApp, + """ + If you want mobile app to handle call (without your B2CApp), provide this argument. If "b2cAppCallData" and "nativeCallData" are provided together, native call is prioritized. + If data is incorrect, mobile app will fallback to b2c app call handling with "b2cAppCallData" + """ + nativeCallData: SendVoIPStartMessageDataForCallHandlingByNative + }`, }, { access: true, - type: 'input SendVoIPStartMessageInput { ' + - 'dv: Int!, ' + - 'sender: SenderFieldInput!, ' + - 'app: B2CAppWhereUniqueInput!, ' + - '"""' + - 'Should be "addressKey" of B2CAppProperty / Property for which you want to send message' + - '"""' + - 'addressKey: String!, ' + - '"""' + - 'Name of unit, same as in Property map' + - '"""' + - 'unitName: String!, ' + - '"""' + - 'Type of unit, same as in Property map' + - '"""' + - 'unitType: AllowedVoIPMessageUnitType!, ' + - 'data: SendVoIPStartMessageData! ' + - '}', + type: `input SendVoIPStartMessageInput { + dv: Int!, + sender: SenderFieldInput!, + app: B2CAppWhereUniqueInput!, + """ + Should be "addressKey" of B2CAppProperty / Property for which you want to send message + """ + addressKey: String!, + """ + Name of unit, same as in Property map + """ + unitName: String!, + """ + Type of unit, same as in Property map + """ + unitType: AllowedVoIPMessageUnitType!, + callData: SendVoIPStartMessageData! + }`, }, { access: true, - type: 'type SendVoIPStartMessageOutput { ' + - '"""' + - 'Count of all Organization Contacts, which we possibly could\'ve sent messages to' + - '"""' + - 'verifiedContactsCount: Int, ' + - '"""' + - 'Count of Messages that will be sent, one for each verified Resident' + - '"""' + - 'createdMessagesCount: Int,' + - '"""' + - 'Count of Messages which was not created due to some internal error' + - '"""' + - 'erroredMessagesCount: Int' + - '}', + type: `type SendVoIPStartMessageOutput { + """ + Count of all Organization Contacts, which we possibly could've sent messages to + """ + verifiedContactsCount: Int, + """ + Count of Messages that will be sent, one for each verified Resident + """ + createdMessagesCount: Int, + """ + Count of Messages which was not created due to some internal error + """ + erroredMessagesCount: Int + }`, }, { access: true, @@ -199,7 +221,11 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer }, resolver: async (parent, args, context) => { const { data: argsData } = args - const { dv, sender, app, addressKey, unitName, unitType, data } = argsData + const { dv, sender, app, addressKey, unitName, unitType, callData } = argsData + + if (!callData.b2cAppCallData && !callData.nativeCallData) { + throw new GQLError(ERRORS.CALL_DATA_NOT_PROVIDED, context) + } checkDvAndSender(argsData, ERRORS.DV_VERSION_MISMATCH, ERRORS.WRONG_SENDER_FORMAT, context) @@ -229,13 +255,13 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer }, 'id app { id name }', { first: 1 }) if (!b2cAppProperty) { - logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) throw new GQLError(ERRORS.PROPERTY_NOT_FOUND, context) } logInfoStats.isPropertyFound = true if (!b2cAppProperty.app) { - logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) throw new GQLError(ERRORS.APP_NOT_FOUND, context) } logInfoStats.isAppFound = true @@ -254,7 +280,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer logInfoStats.verifiedContactsCount = verifiedContactsOnUnit.length if (!verifiedContactsOnUnit?.length) { - logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) return { verifiedContactsCount: 0, createdMessagesCount: 0, @@ -285,7 +311,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer logInfoStats.verifiedResidentsCount = residentsWithVerifiedContactOnAddress.length if (!residentsWithVerifiedContactOnAddress?.length) { - logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) return { verifiedContactsCount: verifiedContactsOnUnit.length, createdMessagesCount: 0, @@ -329,7 +355,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer ) } catch (err) { logInfoStats.step = 'check limits' - logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats, errors: err }) + logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats, errors: err }) throw err } @@ -340,20 +366,33 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer // .filter(resident => !rateLimitsErrorsByUserIds[resident.user]) .map(async (resident) => { // NOTE(YEgorLu): as in domains/notification/constants/config for VOIP_INCOMING_CALL_MESSAGE_TYPE - const preparedDataArgs = { + let preparedDataArgs = { B2CAppId: b2cAppId, - B2CAppContext: data.B2CAppContext, B2CAppName: b2cAppName, residentId: resident.id, - callId: data.callId, - voipType: data.voipType, - voipAddress: data.voipAddress, - voipLogin: data.voipLogin, - voipPassword: data.voipPassword, - voipDtfmCommand: data.voipPanels?.[0]?.dtfmCommand, - voipPanels: data.voipPanels, - stun: data.stun, - codec: data.codec, + callId: callData.callId, + } + + if (callData.b2cAppCallData) { + preparedDataArgs = { + ...preparedDataArgs, + B2CAppContext: callData.b2cAppCallData.B2CAppContext, + } + } + + if (callData.nativeCallData) { + preparedDataArgs = { + ...preparedDataArgs, + voipType: MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, + voipAddress: callData.nativeCallData.voipAddress, + voipLogin: callData.nativeCallData.voipLogin, + voipPassword: callData.nativeCallData.voipPassword, + voipDtfmCommand: callData.nativeCallData.voipPanels?.[0]?.dtfmCommand, + voipPanels: callData.nativeCallData.voipPanels, + stunServers: callData.nativeCallData.stunServers, + stun: callData.nativeCallData.stunServers?.[0], + codec: callData.nativeCallData.codec, + } } const requiredMetaData = get(MESSAGE_META[VOIP_INCOMING_CALL_MESSAGE_TYPE], 'data', {}) @@ -417,7 +456,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer if (sendMessageStats.some(stat => !stat.error)) { logInfoStats.isStatusCached = await setCallStatus({ b2cAppId, - callId: data.callId, + callId: callData.callId, status: CALL_STATUS_START_SENT, // NOTE(YEgorLu): we can use uniqKey for that: [pushType, b2cAppId, callId, userId, YYYY-MM-DD].join() // but this would require to check current and previous day/period @@ -427,7 +466,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer } logInfoStats.step = 'result' - logInfo({ b2cAppId, callId: data.callId, stats: logInfoStats }) + logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) return { verifiedContactsCount: verifiedContactsOnUnit.length, diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js index 773401527e4..8873f79f8a5 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js @@ -64,8 +64,9 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: faker.random.alphaNumeric(3), unitType: FLAT_UNIT_TYPE, - data: { + callData: { callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: '' }, }, }) if (expectError) { @@ -89,8 +90,9 @@ describe('SendVoIPStartMessageService', () => { addressKey: faker.datatype.uuid(), unitName: faker.random.alphaNumeric(3), unitType: FLAT_UNIT_TYPE, - data: { + callData: { callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: '' }, }, }) }, ERRORS.PROPERTY_NOT_FOUND) @@ -105,13 +107,35 @@ describe('SendVoIPStartMessageService', () => { addressKey: faker.datatype.uuid(), unitName: faker.random.alphaNumeric(3), unitType: FLAT_UNIT_TYPE, - data: { + callData: { callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: '' }, }, }) }, ERRORS.PROPERTY_NOT_FOUND) }) + test('should throw error if callData does not contain data for b2c or native call', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + await expectToThrowGQLErrorToResult(async () => { + await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + callData: { + callId: faker.datatype.uuid(), + }, + }) + }, ERRORS.CALL_DATA_NOT_PROVIDED) + }) + }) describe('Logic', () => { @@ -164,8 +188,9 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: unitType, - data: { + callData: { callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: '' }, }, }) @@ -209,18 +234,21 @@ describe('SendVoIPStartMessageService', () => { await Promise.all(prepareDataPromises) const dataAttrs = { - B2CAppContext: faker.random.alphaNumeric(10), callId: faker.datatype.uuid(), - voipType: 'sip', - voipAddress: faker.internet.ip(), - voipLogin: faker.internet.userName(), - voipPassword: faker.internet.password(), - voipPanels: [ - { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, - { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, - ], - stun: faker.internet.ip(), - codec: 'vp8', + b2cAppCallData: { + B2CAppContext: faker.random.alphaNumeric(10), + }, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [ + { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + ], + stunServers: [faker.internet.ip(), faker.internet.ip()], + codec: 'vp8', + }, } const [result] = await sendVoIPStartMessageByTestClient(admin, { @@ -228,7 +256,7 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: unitType, - data: dataAttrs, + callData: dataAttrs, }) expect(result.verifiedContactsCount).toBe(residentsCount) expect(result.createdMessagesCount).toBe(residentsCount) @@ -247,18 +275,19 @@ describe('SendVoIPStartMessageService', () => { meta: expect.objectContaining({ data: expect.objectContaining({ B2CAppId: b2cApp.id, - B2CAppContext: dataAttrs.B2CAppContext, + B2CAppContext: dataAttrs.b2cAppCallData.B2CAppContext, B2CAppName: b2cApp.name, residentId: residentId, callId: dataAttrs.callId, - voipType: dataAttrs.voipType, - voipAddress: dataAttrs.voipAddress, - voipLogin: dataAttrs.voipLogin, - voipPassword: dataAttrs.voipPassword, - voipDtfmCommand: dataAttrs.voipPanels[0].dtfmCommand, + voipType: 'sip', + voipAddress: dataAttrs.nativeCallData.voipAddress, + voipLogin: dataAttrs.nativeCallData.voipLogin, + voipPassword: dataAttrs.nativeCallData.voipPassword, + voipDtfmCommand: dataAttrs.nativeCallData.voipPanels[0].dtfmCommand, //voipPanels: dataAttrs.voipPanels, needs to be added in notification constants - stun: dataAttrs.stun, - codec: dataAttrs.codec, + stun: dataAttrs.nativeCallData.stunServers[0], + //stunServers: dataAttrs.nativeCallData.stunServers, + codec: dataAttrs.nativeCallData.codec, }), }), }) @@ -290,8 +319,9 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: unitType, - data: { + callData: { callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: '' }, }, }) @@ -324,8 +354,9 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: unitType, - data: { + callData: { callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: '' }, }, }) @@ -356,8 +387,9 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: unitType, - data: { + callData: { callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: '' }, }, }) @@ -416,7 +448,7 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: unitType, - data: { callId }, + callData: { callId, b2cAppCallData: { B2CAppContext: '' } }, }) expect(result.verifiedContactsCount).toBe(residentsCount) expect(result.createdMessagesCount).toBe(residentsCount) @@ -465,7 +497,7 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: unitType, - data: { callId }, + callData: { callId, b2cAppCallData: { B2CAppContext: '' } }, }) expect(result.verifiedContactsCount).toBe(residentsCount) expect(result.createdMessagesCount).toBe(residentsCount) @@ -499,8 +531,9 @@ describe('SendVoIPStartMessageService', () => { addressKey: property.addressKey, unitName: unitName, unitType: FLAT_UNIT_TYPE, - data: { + callData: { callId: callId, + b2cAppCallData: { B2CAppContext: '' }, }, }) expect(result.verifiedContactsCount).toBe(1) diff --git a/apps/condo/domains/miniapp/utils/testSchema/index.js b/apps/condo/domains/miniapp/utils/testSchema/index.js index 5c2b303190b..86354a41293 100644 --- a/apps/condo/domains/miniapp/utils/testSchema/index.js +++ b/apps/condo/domains/miniapp/utils/testSchema/index.js @@ -398,9 +398,6 @@ async function sendVoIPStartMessageByTestClient (client, extraAttrs = {}) { dv: 1, sender, ...extraAttrs, - data: { - ...get(extraAttrs, 'data', {}), - }, } const { data, errors } = await client.mutate(SEND_VOIP_START_MESSAGE_MUTATION, { data: attrs }) diff --git a/apps/condo/schema.graphql b/apps/condo/schema.graphql index 754fbdbbfe4..44a81a42dc7 100644 --- a/apps/condo/schema.graphql +++ b/apps/condo/schema.graphql @@ -93722,11 +93722,6 @@ type SendB2BAppPushMessageOutput { id: String! } -enum VoIPType { - """Makes mobile app use it's call app instead of B2CApp's""" - sip -} - input VoIPPanel { """Dtfm command for panel""" dtfmCommand: String! @@ -93735,43 +93730,55 @@ input VoIPPanel { name: String! } -input SendVoIPStartMessageData { - """ - If you want your B2CApp to handle incoming VoIP call, provide this argument. Otherwise provide all others - """ - B2CAppContext: String - - """ - Unique value for each call session between panel and resident (means same for different devices also). Must be provided for correct work with multiple devices that use same voip call.F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages - """ - callId: String! - - """ - If "sip" was passed, mobile device will try to start native call. Info about other values will be added later - """ - voipType: VoIPType +input SendVoIPStartMessageDataForCallHandlingByB2CApp { + """Data that will be provided to B2CApp. May be stringified JSON""" + B2CAppContext: String! +} +input SendVoIPStartMessageDataForCallHandlingByNative { """Address of sip server, which device should connect to""" - voipAddress: String + voipAddress: String! """Login for connection to sip server""" - voipLogin: String + voipLogin: String! """Password for connection to sip server""" - voipPassword: String + voipPassword: String! """ Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change """ - voipPanels: [VoIPPanel] + voipPanels: [VoIPPanel!]! - """Stun server url""" - stun: String + """ + Stun server urls. Are used to determine device public ip for media streams + """ + stunServers: [String!] """Preferred codec (usually vp8)""" codec: String } +input SendVoIPStartMessageData { + """ + Unique value for each call session between panel and resident (means same for different devices also). + Must be provided for correct work with multiple devices that use same voip call. + F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages + """ + callId: String! + + """ + If you want your B2CApp to handle incoming VoIP call, provide this argument. + """ + b2cAppCallData: SendVoIPStartMessageDataForCallHandlingByB2CApp + + """ + If you want mobile app to handle call (without your B2CApp), provide this argument. If "b2cAppCallData" and "nativeCallData" are provided together, native call is prioritized. + If data is incorrect, mobile app will fallback to b2c app call handling with "b2cAppCallData" + """ + nativeCallData: SendVoIPStartMessageDataForCallHandlingByNative +} + input SendVoIPStartMessageInput { dv: Int! sender: SenderFieldInput! @@ -93787,7 +93794,7 @@ input SendVoIPStartMessageInput { """Type of unit, same as in Property map""" unitType: AllowedVoIPMessageUnitType! - data: SendVoIPStartMessageData! + callData: SendVoIPStartMessageData! } type SendVoIPStartMessageOutput { @@ -108177,13 +108184,21 @@ type Mutation { `{ "code": "BAD_USER_INPUT", "type": "PROPERTY_NOT_FOUND", - "message": "Unable to find Property or B2CAppProperty by provided addressKey" + "message": "Unable to find Property or B2CAppProperty by provided addressKey", + "messageForUser": "Building not found for the provided \"addressKey\"" }` `{ "code": "BAD_USER_INPUT", "type": "APP_NOT_FOUND", - "message": "Unable to find B2CApp." + "message": "Unable to find B2CApp.", + "messageForUser": "Application not found for the provided \"id\"" + }` + + `{ + "code": "BAD_USER_INPUT", + "type": "CALL_DATA_NOT_PROVIDED", + "message": "\"b2cAppCallData\" or \"nativeCallData\" or both should be provided" }` """ sendVoIPStartMessage(data: SendVoIPStartMessageInput!): SendVoIPStartMessageOutput diff --git a/apps/condo/schema.ts b/apps/condo/schema.ts index 003dd1c6f40..d5c971fa8b8 100644 --- a/apps/condo/schema.ts +++ b/apps/condo/schema.ts @@ -50888,13 +50888,21 @@ export type Mutation = { * `{ * "code": "BAD_USER_INPUT", * "type": "PROPERTY_NOT_FOUND", - * "message": "Unable to find Property or B2CAppProperty by provided addressKey" + * "message": "Unable to find Property or B2CAppProperty by provided addressKey", + * "messageForUser": "Building not found for the provided \"addressKey\"" * }` * * `{ * "code": "BAD_USER_INPUT", * "type": "APP_NOT_FOUND", - * "message": "Unable to find B2CApp." + * "message": "Unable to find B2CApp.", + * "messageForUser": "Application not found for the provided \"id\"" + * }` + * + * `{ + * "code": "BAD_USER_INPUT", + * "type": "CALL_DATA_NOT_PROVIDED", + * "message": "\"b2cAppCallData\" or \"nativeCallData\" or both should be provided" * }` */ sendVoIPStartMessage?: Maybe; @@ -89832,31 +89840,46 @@ export type SendOrganizationEmployeeRequestInput = { }; export type SendVoIpStartMessageData = { - /** If you want your B2CApp to handle incoming VoIP call, provide this argument. Otherwise provide all others */ - B2CAppContext?: InputMaybe; - /** Unique value for each call session between panel and resident (means same for different devices also). Must be provided for correct work with multiple devices that use same voip call.F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages */ + /** If you want your B2CApp to handle incoming VoIP call, provide this argument. */ + b2cAppCallData?: InputMaybe; + /** + * Unique value for each call session between panel and resident (means same for different devices also). + * Must be provided for correct work with multiple devices that use same voip call. + * F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages + */ callId: Scalars['String']['input']; + /** + * If you want mobile app to handle call (without your B2CApp), provide this argument. If "b2cAppCallData" and "nativeCallData" are provided together, native call is prioritized. + * If data is incorrect, mobile app will fallback to b2c app call handling with "b2cAppCallData" + */ + nativeCallData?: InputMaybe; +}; + +export type SendVoIpStartMessageDataForCallHandlingByB2CApp = { + /** Data that will be provided to B2CApp. May be stringified JSON */ + B2CAppContext: Scalars['String']['input']; +}; + +export type SendVoIpStartMessageDataForCallHandlingByNative = { /** Preferred codec (usually vp8) */ codec?: InputMaybe; - /** Stun server url */ - stun?: InputMaybe; + /** Stun server urls. Are used to determine device public ip for media streams */ + stunServers?: InputMaybe>; /** Address of sip server, which device should connect to */ - voipAddress?: InputMaybe; + voipAddress: Scalars['String']['input']; /** Login for connection to sip server */ - voipLogin?: InputMaybe; + voipLogin: Scalars['String']['input']; /** Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change */ - voipPanels?: InputMaybe>>; + voipPanels: Array; /** Password for connection to sip server */ - voipPassword?: InputMaybe; - /** If "sip" was passed, mobile device will try to start native call. Info about other values will be added later */ - voipType?: InputMaybe; + voipPassword: Scalars['String']['input']; }; export type SendVoIpStartMessageInput = { /** Should be "addressKey" of B2CAppProperty / Property for which you want to send message */ addressKey: Scalars['String']['input']; app: B2CAppWhereUniqueInput; - data: SendVoIpStartMessageData; + callData: SendVoIpStartMessageData; dv: Scalars['Int']['input']; sender: SenderFieldInput; /** Name of unit, same as in Property map */ @@ -117645,11 +117668,6 @@ export type VoIpPanel = { name: Scalars['String']['input']; }; -export enum VoIpType { - /** Makes mobile app use it's call app instead of B2CApp's */ - Sip = 'sip' -} - /** Webhooks are a way that the APP can send automated web callback with some messages to other apps or system to inform them about any updates. How does it work: 1. When objects are created or changed, we make requests to the GraphQL API to get data on behalf of the specified user; 2. Then we send the data to remote url. Webhook model contains basic configuration of integration, such as external server url, name, encryption parameters and so on. */ export type Webhook = { __typename?: 'Webhook'; From eec97dfe08a1f55f1827a247be20be602c0265c2 Mon Sep 17 00:00:00 2001 From: YEgorLu Date: Wed, 8 Apr 2026 12:53:02 +0500 Subject: [PATCH 3/6] fix(condo): DOMA-12905 remote title + body from mutation, as they are autoapplied now --- .../domains/miniapp/schema/SendVoIPStartMessageService.js | 3 --- apps/condo/lang/en/en.json | 2 -- apps/condo/lang/es/es.json | 2 -- apps/condo/lang/ru/ru.json | 2 -- 4 files changed, 9 deletions(-) diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js index 7b985e32582..267b7f81802 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js @@ -363,7 +363,6 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer /** @type {Array>} */ const sendMessagePromises = verifiedResidentsWithUniqueUsers - // .filter(resident => !rateLimitsErrorsByUserIds[resident.user]) .map(async (resident) => { // NOTE(YEgorLu): as in domains/notification/constants/config for VOIP_INCOMING_CALL_MESSAGE_TYPE let preparedDataArgs = { @@ -406,8 +405,6 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer to: { user: { id: resident.user } }, meta: { dv, - title: i18n('api.miniapp.sendVoIPStartMessage.pushData.title', { locale: userIdToLocale[resident.user] }), - body: i18n('api.miniapp.sendVoIPStartMessage.pushData.body', { locale: userIdToLocale[resident.user] }), data: metaData, }, } diff --git a/apps/condo/lang/en/en.json b/apps/condo/lang/en/en.json index 7d649411692..e237d99e0a9 100644 --- a/apps/condo/lang/en/en.json +++ b/apps/condo/lang/en/en.json @@ -487,8 +487,6 @@ "api.miniapp.sendVoIPStartMessage.APP_NOT_FOUND": "Application not found for the provided \"id\"", "api.miniapp.sendVoIPStartMessage.DV_VERSION_MISMATCH": "Invalid data version value", "api.miniapp.sendVoIPStartMessage.WRONG_SENDER_FORMAT": "Invalid format for the \"sender\" field", - "api.miniapp.sendVoIPStartMessage.pushData.title": "Intercom call", - "api.miniapp.sendVoIPStartMessage.pushData.body": "Intercom call", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "Response from NewsSharing miniapp was successful, but the data format was incorrect. Please consult with miniapp developer", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "Could not get a successful response from NewsSharing miniapp. Please check the network and url configuration", "api.news.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Provided b2bAppContext.app is not a NewsSharing miniapp", diff --git a/apps/condo/lang/es/es.json b/apps/condo/lang/es/es.json index 8210bc80b5b..d952b8a2ef9 100644 --- a/apps/condo/lang/es/es.json +++ b/apps/condo/lang/es/es.json @@ -487,8 +487,6 @@ "api.miniapp.sendVoIPStartMessage.APP_NOT_FOUND": "No se encontró la aplicación para el \"id\" proporcionado", "api.miniapp.sendVoIPStartMessage.DV_VERSION_MISMATCH": "Valor de versión de datos no válido", "api.miniapp.sendVoIPStartMessage.WRONG_SENDER_FORMAT": "Formato no válido para el campo \"sender\"", - "api.miniapp.sendVoIPStartMessage.pushData.title": "Llamada del interfono", - "api.miniapp.sendVoIPStartMessage.pushData.body": "Llamada del interfono", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "La solicitud a la miniapp ha devuelto datos incorrectos", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "La solicitud a la miniapp se completó con un error.", "api.news.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Esta miniapp no admite la funcionalidad de envío de noticias", diff --git a/apps/condo/lang/ru/ru.json b/apps/condo/lang/ru/ru.json index 00472d33ef6..8d2775f6b58 100644 --- a/apps/condo/lang/ru/ru.json +++ b/apps/condo/lang/ru/ru.json @@ -487,8 +487,6 @@ "api.miniapp.sendVoIPStartMessage.APP_NOT_FOUND": "Не удалось найти приложение по переданному \"id\"", "api.miniapp.sendVoIPStartMessage.DV_VERSION_MISMATCH": "Неверное значение версии данных", "api.miniapp.sendVoIPStartMessage.WRONG_SENDER_FORMAT": "Неверный формат поля \"sender\"", - "api.miniapp.sendVoIPStartMessage.pushData.title": "Звонок домофона", - "api.miniapp.sendVoIPStartMessage.pushData.body": "Звонок домофона", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_BAD_RESPONSE": "Запрос в миниапп вернул некорректные данные", "api.news.getNewsSharingRecipients.NEWS_SHARING_APP_REQUEST_FAILED": "Запрос в миниапп завершился с ошибкой", "api.news.getNewsSharingRecipients.NOT_NEWS_SHARING_APP": "Этот миниапп не поддерживает функциональность отправки новостей", From b4df992d00895100ceb737817c13b36f855875c0 Mon Sep 17 00:00:00 2001 From: YEgorLu Date: Wed, 15 Apr 2026 12:01:10 +0500 Subject: [PATCH 4/6] feat(condo): DOMA-12905 add mutation to send voip messages by address --- apps/condo/domains/miniapp/constants.js | 6 + .../schema/SendVoIPStartMessageService.js | 212 +++++--- .../SendVoIPStartMessageService.test.js | 96 ---- .../sendVoIPStartMessageService.spec.js | 513 ++++++++++++++++++ .../notification/constants/constants.js | 2 + apps/condo/schema.graphql | 6 +- 6 files changed, 659 insertions(+), 176 deletions(-) create mode 100644 apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js diff --git a/apps/condo/domains/miniapp/constants.js b/apps/condo/domains/miniapp/constants.js index e346a03ba54..e3de8c3ba6d 100644 --- a/apps/condo/domains/miniapp/constants.js +++ b/apps/condo/domains/miniapp/constants.js @@ -133,6 +133,9 @@ const ACCESS_TOKEN_SESSION_ID_PREFIX = `${Buffer.from('b2bAccessToken').toString const ACCESS_TOKEN_UPDATE_MANY_CHUNK_SIZE = 500 +const MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY = 'sip' // without this constant mobile app will not try to make native call +const DEFAULT_VOIP_TYPE = 0 + module.exports = { ALL_APPS_CATEGORY, CONNECTED_APPS_CATEGORY, @@ -210,4 +213,7 @@ module.exports = { DEFAULT_NOTIFICATION_WINDOW_DURATION_IN_SECONDS, CALL_DATA_NOT_PROVIDED_ERROR, + + MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, + DEFAULT_VOIP_TYPE, } \ No newline at end of file diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js index 267b7f81802..c02300341c6 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js @@ -6,7 +6,6 @@ const { GQLError, GQLErrorCode: { BAD_USER_INPUT } } = require('@open-condo/keys const { getLogger } = require('@open-condo/keystone/logging') const { checkDvAndSender } = require('@open-condo/keystone/plugins/dvAndSender') const { GQLCustomSchema, find, getByCondition } = require('@open-condo/keystone/schema') -const { i18n } = require('@open-condo/locales/loader') const { COMMON_ERRORS } = require('@condo/domains/common/constants/errors') const access = require('@condo/domains/miniapp/access/SendVoIPStartMessageService') @@ -16,6 +15,7 @@ const { DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, DEFAULT_NOTIFICATION_WINDOW_DURATION_IN_SECONDS, CALL_DATA_NOT_PROVIDED_ERROR, + MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, DEFAULT_VOIP_TYPE, } = require('@condo/domains/miniapp/constants') const { B2CAppProperty } = require('@condo/domains/miniapp/utils/serverSchema') const { setCallStatus, CALL_STATUS_START_SENT } = require('@condo/domains/miniapp/utils/voip') @@ -41,6 +41,7 @@ const DEBUG_APP_ID = conf.MINIAPP_PUSH_MESSAGE_DEBUG_APP_ID const DEBUG_APP_ENABLED = !!DEBUG_APP_ID const DEBUG_APP_SETTINGS = DEBUG_APP_ENABLED ? Object.freeze(JSON.parse(conf.MINIAPP_PUSH_MESSAGE_DEBUG_APP_SETTINGS)) : {} const ALLOWED_UNIT_TYPES = [FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE] +const VOIP_TYPE_CUSTOM_FIELD_ID = conf.VOIP_TYPE_CUSTOM_FIELD_ID const redisGuard = new RedisGuard() @@ -81,15 +82,9 @@ const ERRORS = { }, } -/* - """ - If "sip" was passed, mobile device will try to start native call. Info about other values will be added later - """ - voipType: VoIPType, -*/ -const MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY = 'sip' // without this constant mobile app will not try to make native call +const B2CAPP_VOIP_TYPE = 'b2cApp' -const logInfo = ({ b2cAppId, callId, stats, errors }) => { +function logInfo ({ b2cAppId, callId, stats, errors }) { logger.info({ msg: `${SERVICE_NAME} stats`, entityName: 'B2CApp', entityId: b2cAppId, data: { callId, stats }, err: errors }) } @@ -144,6 +139,10 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer Preferred codec (usually vp8) """ codec: String + """ + Type of the native client to handle call. Values defined in mobile app. "0" used as legacy "${MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY}" voipType for backwards compatibility. + """ + voipType: Int = ${DEFAULT_VOIP_TYPE} }`, }, { @@ -161,7 +160,6 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer b2cAppCallData: SendVoIPStartMessageDataForCallHandlingByB2CApp, """ If you want mobile app to handle call (without your B2CApp), provide this argument. If "b2cAppCallData" and "nativeCallData" are provided together, native call is prioritized. - If data is incorrect, mobile app will fallback to b2c app call handling with "b2cAppCallData" """ nativeCallData: SendVoIPStartMessageDataForCallHandlingByNative }`, @@ -229,20 +227,24 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer checkDvAndSender(argsData, ERRORS.DV_VERSION_MISMATCH, ERRORS.WRONG_SENDER_FORMAT, context) - const logInfoStats = { - step: 'init', - addressKey, - unitName, - unitType, - verifiedContactsCount: 0, - residentsCount: 0, - verifiedResidentsCount: 0, - createdMessagesCount: 0, - erroredMessagesCount: 0, - createMessageErrors: [], - isStatusCached: false, - isPropertyFound: false, - isAppFound: false, + const logContext = { + logInfoStats: { + step: 'init', + addressKey, + unitName, + unitType, + verifiedContactsCount: 0, + residentsCount: 0, + verifiedResidentsCount: 0, + createdMessagesCount: 0, + erroredMessagesCount: 0, + createMessageErrors: [], + isStatusCached: false, + isPropertyFound: false, + isAppFound: false, + }, + b2cAppId: app.id, + callId: callData.callId, } // 1) Check B2CApp and B2CAppProperty @@ -255,18 +257,20 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer }, 'id app { id name }', { first: 1 }) if (!b2cAppProperty) { - logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) + logInfo(logContext) throw new GQLError(ERRORS.PROPERTY_NOT_FOUND, context) } - logInfoStats.isPropertyFound = true + logContext.logInfoStats.isPropertyFound = true if (!b2cAppProperty.app) { - logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) + logInfo(logContext) throw new GQLError(ERRORS.APP_NOT_FOUND, context) } - logInfoStats.isAppFound = true + logContext.logInfoStats.isAppFound = true const b2cAppName = b2cAppProperty.app.name + + let actors = [] // 2) Find Users const verifiedContactsOnUnit = await find('Contact', { @@ -276,11 +280,18 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer isVerified: true, deletedAt: null, }) - logInfoStats.step = 'find verified contacts' - logInfoStats.verifiedContactsCount = verifiedContactsOnUnit.length + for (const contact of verifiedContactsOnUnit) { + actors.push({ contact }) + } + const actorByPhone = actors.reduce((byPhone, actor) => { + byPhone[actor.contact.phone] = actor + return byPhone + }, {}) + logContext.logInfoStats.step = 'find verified contacts' + logContext.logInfoStats.verifiedContactsCount = verifiedContactsOnUnit.length if (!verifiedContactsOnUnit?.length) { - logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) + logInfo(logContext) return { verifiedContactsCount: 0, createdMessagesCount: 0, @@ -292,26 +303,52 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer unitName_i: unitName, unitType: unitType, deletedAt: null, - property: { id_in: [...new Set(verifiedContactsOnUnit.map(contact => contact.property))] }, + property: { id_in: [...new Set(actors.map(actor => actor.contact.property))] }, }) - logInfoStats.step = 'find residents' - logInfoStats.residentsCount = residentsOnUnit.length + logContext.logInfoStats.step = 'find residents' + logContext.logInfoStats.residentsCount = residentsOnUnit.length + + if (!residentsOnUnit.length) { + logInfo(logContext) + return { + verifiedContactsCount: verifiedContactsOnUnit.length, + createdMessagesCount: 0, + erroredMessagesCount: 0, + } + } // NOTE(YEgorLu): doing same as Resident.isVerifiedByManagingCompany virtual field const usersOfContacts = await find('User', { - phone_in: [...new Set(verifiedContactsOnUnit.map(contact => contact.phone))], + phone_in: [...new Set(actors.map(actor => actor.contact.phone))], deletedAt: null, type: RESIDENT, isPhoneVerified: true, }) + for (const user of usersOfContacts) { + const actor = actorByPhone[user.phone] + if (actor) { + actor.user = user + } + } + actors = actors.filter(actor => !!actor.user) + + const actorsByUserIds = actors.reduce((byId, actor) => { + byId[actor.user.id] = actor + return byId + }, {}) - const uniqueUserIdsOfContacts = new Set(usersOfContacts.map(user => user.id)) - const residentsWithVerifiedContactOnAddress = residentsOnUnit.filter(resident => uniqueUserIdsOfContacts.has(resident.user)) - logInfoStats.step = 'verify residents' - logInfoStats.verifiedResidentsCount = residentsWithVerifiedContactOnAddress.length + for (const resident of residentsOnUnit) { + const actor = actorsByUserIds[resident.user] + if (actor) { + actor.resident = resident + } + } + actors = actors.filter(actor => !!actor.resident) + logContext.logInfoStats.step = 'verify residents' + logContext.logInfoStats.verifiedResidentsCount = actors.length - if (!residentsWithVerifiedContactOnAddress?.length) { - logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) + if (!actors?.length) { + logInfo(logContext) return { verifiedContactsCount: verifiedContactsOnUnit.length, createdMessagesCount: 0, @@ -319,16 +356,6 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer } } - const userIdToLocale = {} - usersOfContacts.forEach(user => userIdToLocale[user.id] = user.locale) - - // NOTE(YEgorLu): there should be maximum 1 Resident for user + address + unitName + unitType, but just in case lets deduplicate - const residentsGroupedByUser = residentsWithVerifiedContactOnAddress.reduce((groupedByUser, resident) => { - groupedByUser[resident.user] = resident - return groupedByUser - }, {}) - const verifiedResidentsWithUniqueUsers = Object.values(residentsGroupedByUser) - let appSettings if (!DEBUG_APP_ENABLED || b2cAppId !== DEBUG_APP_ID) { @@ -354,16 +381,35 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer context, ) } catch (err) { - logInfoStats.step = 'check limits' - logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats, errors: err }) + logContext.logInfoStats.step = 'check limits' + logInfo({ ...logContext, errors: err }) throw err } - const startingMessagesIdsByUserIds = {} + let voipTypeCustomValuesByContacts = {} + if (VOIP_TYPE_CUSTOM_FIELD_ID) { + const voipTypeCustomValues = await find('CustomValue', { + customField: { id: VOIP_TYPE_CUSTOM_FIELD_ID }, + itemId_in: actors.map(actor => actor.contact.id), + deletedAt: null, + }) + voipTypeCustomValuesByContacts = voipTypeCustomValues + .filter(customValue => typeof customValue.data === 'string') + .reduce((acc, customValue) => { + const isDataInProperFormat = /^\d+$/.test(customValue.data) + // if something is wrong put B2CAPP_VOIP_TYPE to force b2cAppCallData + acc[customValue.itemId] = isDataInProperFormat ? customValue.data : B2CAPP_VOIP_TYPE + return acc + }, {}) + } + const startingMessagesIdsByUserIds = {} /** @type {Array>} */ - const sendMessagePromises = verifiedResidentsWithUniqueUsers - .map(async (resident) => { + const sendMessagePromises = actors + .filter(actor => !!actor.resident && !!actor.contact && !!actor.user) + .map(async ({ contact, resident, user }) => { + const customVoIPType = voipTypeCustomValuesByContacts[contact.id] + // NOTE(YEgorLu): as in domains/notification/constants/config for VOIP_INCOMING_CALL_MESSAGE_TYPE let preparedDataArgs = { B2CAppId: b2cAppId, @@ -372,17 +418,18 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer callId: callData.callId, } - if (callData.b2cAppCallData) { - preparedDataArgs = { - ...preparedDataArgs, - B2CAppContext: callData.b2cAppCallData.B2CAppContext, + const needToPasteB2CAppCallData = !callData.nativeCallData || (callData.b2cAppCallData && customVoIPType === B2CAPP_VOIP_TYPE) + + if (!needToPasteB2CAppCallData) { + let voipType = customVoIPType || callData.nativeCallData.voipType || DEFAULT_VOIP_TYPE + // CustomValue says to use B2CAPP_VOIP_TYPE, but we have no b2cAppCallData + if (voipType === B2CAPP_VOIP_TYPE) { + voipType = callData.nativeCallData.voipType || DEFAULT_VOIP_TYPE } - } - if (callData.nativeCallData) { preparedDataArgs = { ...preparedDataArgs, - voipType: MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, + voipType: String(String(voipType) === String(DEFAULT_VOIP_TYPE) ? MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY : voipType), voipAddress: callData.nativeCallData.voipAddress, voipLogin: callData.nativeCallData.voipLogin, voipPassword: callData.nativeCallData.voipPassword, @@ -392,6 +439,11 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer stun: callData.nativeCallData.stunServers?.[0], codec: callData.nativeCallData.codec, } + } else { + preparedDataArgs = { + ...preparedDataArgs, + B2CAppContext: callData.b2cAppCallData.B2CAppContext, + } } const requiredMetaData = get(MESSAGE_META[VOIP_INCOMING_CALL_MESSAGE_TYPE], 'data', {}) @@ -402,56 +454,58 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer const messageAttrs = { sender, type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - to: { user: { id: resident.user } }, + to: { user: { id: user.id } }, meta: { dv, + title: '', // NOTE(YEgorLu): title and body are rewritten by translations for push type + body: '', data: metaData, }, } const res = await sendMessage(context, messageAttrs) if (res?.id) { - startingMessagesIdsByUserIds[resident.user] = res.id + startingMessagesIdsByUserIds[user.id] = res.id } return { resident, result: res } }) // 4) Set status in redis - logInfoStats.step = 'send messages' + logContext.logInfoStats.step = 'send messages' const sendMessageResults = await Promise.allSettled(sendMessagePromises) const sendMessageStats = sendMessageResults.map(promiseResult => { if (promiseResult.status === 'rejected') { - logInfoStats.erroredMessagesCount++ - logInfoStats.createMessageErrors.push(promiseResult.reason) + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(promiseResult.reason) return { error: promiseResult.reason } } const { resident, result } = promiseResult.value if (result.isDuplicateMessage) { - logInfoStats.erroredMessagesCount++ - logInfoStats.createMessageErrors.push(`${resident.id} duplicate message`) + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(`${resident.id} duplicate message`) return { error: `${resident.id} duplicate message` } } if (result.status !== MESSAGE_SENDING_STATUS) { - logInfoStats.erroredMessagesCount++ - logInfoStats.createMessageErrors.push(`${resident.id} invalid status for some reason`) + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(`${resident.id} invalid status for some reason`) return { error: `${resident.id} invalid status for some reason` } } - logInfoStats.createdMessagesCount++ + logContext.logInfoStats.createdMessagesCount++ return result }) for (const messageStat of sendMessageStats) { if (messageStat.error) { - logInfoStats.erroredMessagesCount++ - logInfoStats.createMessageErrors.push(messageStat.error) + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(messageStat.error) continue } - logInfoStats.createdMessagesCount++ + logContext.logInfoStats.createdMessagesCount++ } if (sendMessageStats.some(stat => !stat.error)) { - logInfoStats.isStatusCached = await setCallStatus({ + logContext.logInfoStats.isStatusCached = await setCallStatus({ b2cAppId, callId: callData.callId, status: CALL_STATUS_START_SENT, @@ -462,8 +516,8 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer }) } - logInfoStats.step = 'result' - logInfo({ b2cAppId, callId: callData.callId, stats: logInfoStats }) + logContext.logInfoStats.step = 'result' + logInfo(logContext) return { verifiedContactsCount: verifiedContactsOnUnit.length, diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js index 8873f79f8a5..b5237b9a477 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js @@ -198,102 +198,6 @@ describe('SendVoIPStartMessageService', () => { expect(result.createdMessagesCount).toBe(verifiedResidentsCount) expect(result.erroredMessagesCount).toBe(0) }) - - // NOTE(YEgorLu): in case someone changes notification constants for message type - test('created messages do not exclude provided data', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - - const unitName = faker.random.alphaNumeric(3) - const unitType = FLAT_UNIT_TYPE - - const residentsCount = 3 - const prepareDataPromises = [] - const userIds = [] - const residentsIds = [] - - for (let i = 0; i < residentsCount; i++) { - prepareDataPromises.push((async (admin) => { - const phone = createTestPhone() - const userClient = await makeClientWithResidentUser({}, { phone }) - userIds.push(userClient.user.id) - await createTestContact(admin, organization, property, { - unitName: unitName, - unitType: unitType, - isVerified: true, - phone: phone, - }) - const [resident] = await createTestResident(admin, userClient.user, property, { - unitName: unitName, - unitType: unitType, - }) - residentsIds.push(resident.id) - })(admin)) - } - await Promise.all(prepareDataPromises) - - const dataAttrs = { - callId: faker.datatype.uuid(), - b2cAppCallData: { - B2CAppContext: faker.random.alphaNumeric(10), - }, - nativeCallData: { - voipAddress: faker.internet.ip(), - voipLogin: faker.internet.userName(), - voipPassword: faker.internet.password(), - voipPanels: [ - { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, - { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, - ], - stunServers: [faker.internet.ip(), faker.internet.ip()], - codec: 'vp8', - }, - } - - const [result] = await sendVoIPStartMessageByTestClient(admin, { - app: { id: b2cApp.id }, - addressKey: property.addressKey, - unitName: unitName, - unitType: unitType, - callData: dataAttrs, - }) - expect(result.verifiedContactsCount).toBe(residentsCount) - expect(result.createdMessagesCount).toBe(residentsCount) - expect(result.erroredMessagesCount).toBe(0) - - const createdMessages = await Message.getAll(admin, { - user: { id_in: userIds }, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - }, { first: residentsCount }) - - expect(createdMessages).toHaveLength(residentsCount) - expect([...new Set(createdMessages.map(msg => msg.user))]).toHaveLength(residentsCount) - expect(createdMessages).toEqual( - expect.arrayContaining(residentsIds.map(residentId => { - return expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - B2CAppId: b2cApp.id, - B2CAppContext: dataAttrs.b2cAppCallData.B2CAppContext, - B2CAppName: b2cApp.name, - residentId: residentId, - callId: dataAttrs.callId, - voipType: 'sip', - voipAddress: dataAttrs.nativeCallData.voipAddress, - voipLogin: dataAttrs.nativeCallData.voipLogin, - voipPassword: dataAttrs.nativeCallData.voipPassword, - voipDtfmCommand: dataAttrs.nativeCallData.voipPanels[0].dtfmCommand, - //voipPanels: dataAttrs.voipPanels, needs to be added in notification constants - stun: dataAttrs.nativeCallData.stunServers[0], - //stunServers: dataAttrs.nativeCallData.stunServers, - codec: dataAttrs.nativeCallData.codec, - }), - }), - }) - })) - ) - }) test('returns zeroish stats when no verified contacts found on unit', async () => { const [b2cApp] = await createTestB2CApp(admin) diff --git a/apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js b/apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js new file mode 100644 index 00000000000..c6ec162692c --- /dev/null +++ b/apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js @@ -0,0 +1,513 @@ +// eslint-disable-next-line import/order +const { generateUUIDv4 } = require('@open-condo/miniapp-utils/helpers/uuid') +global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID = generateUUIDv4() +jest.doMock('@open-condo/config', () => { + const actual = jest.requireActual('@open-condo/config') + return new Proxy(actual, { + set () {}, + get (_t, p) { + if (p === 'VOIP_TYPE_CUSTOM_FIELD_ID') { + return global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID + } + return actual[p] + }, + }) +}) + +// eslint-disable-next-line import/order +const index = require('@app/condo/index') +// eslint-disable-next-line import/order +const { faker } = require('@faker-js/faker') + +// eslint-disable-next-line import/order +const { setFakeClientMode } = require('@open-condo/keystone/test.utils') +const { + makeLoggedInAdminClient, +// eslint-disable-next-line import/order +} = require('@open-condo/keystone/test.utils') + +const { createTestContact } = require('@condo/domains/contact/utils/testSchema') +const { MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, DEFAULT_VOIP_TYPE } = require('@condo/domains/miniapp/constants') +const { + createTestB2CApp, + sendVoIPStartMessageByTestClient, + createTestB2CAppProperty, + createTestCustomValue, + createTestB2BApp, + createTestB2BAppAccessRight, + createTestB2BAppContext, + createTestB2BAppAccessRightSet, + createTestAppMessageSetting, +} = require('@condo/domains/miniapp/utils/testSchema') +const { + VOIP_INCOMING_CALL_MESSAGE_TYPE, +} = require('@condo/domains/notification/constants/constants') +const { Message } = require('@condo/domains/notification/utils/testSchema') +const { FLAT_UNIT_TYPE } = require('@condo/domains/property/constants/common') +const { makeClientWithResidentAccessAndProperty } = require('@condo/domains/property/utils/testSchema') +const { createTestResident } = require('@condo/domains/resident/utils/testSchema') +const { + makeClientWithResidentUser, + createTestPhone, + makeClientWithServiceUser, +} = require('@condo/domains/user/utils/testSchema') + +async function prepareSingleActor ({ admin, organization, property, unitName, unitType }) { + const phone = createTestPhone() + const userClient = await makeClientWithResidentUser({}, { phone }) + const [contact] = await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: true, + phone: phone, + }) + const [resident] = await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + + return { + user: userClient.user, + contact, + resident, + } +} + +describe('sendVoIPStartMessageService spec', () => { + setFakeClientMode(index) + + let admin + + afterAll(async () => { + jest.unmock('@open-condo/config') + delete global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID + }) + + beforeAll(async () => { + await index.keystone.lists.CustomField.adapter.create({ + id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID, + dv: 1, + v: 1, + name: faker.random.alphaNumeric(8), + modelName: 'Contact', + type: 'String', + validationRules: null, + isVisible: false, + priority: 0, + isUniquePerObject: true, + staffCanRead: false, + sender: { dv: 1, fingerprint: faker.random.alphaNumeric(8) }, + }) + + admin = await makeLoggedInAdminClient() + }) + + describe('Logic', () => { + + describe('voipType priority', () => { + + test('complex case with everything', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const serviceUser = await makeClientWithServiceUser() + const [b2bApp] = await createTestB2BApp(admin) + await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) + const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) + await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + const residentsCount = 4 + const prepareDataPromises = [] + const actors = [] + + for (let i = 0; i < residentsCount; i++) { + prepareDataPromises.push((async ({ admin, organization, property, unitName, unitType }) => { + const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) + actors.push(actor) + })({ admin, organization, property, unitName, unitType })) + } + await Promise.all(prepareDataPromises) + + const normalVoIPType = 1 + const resident1VoIPType = 2 + const resident2VoIPType = 3 + const resident3VoIPType = 'b2cApp' + + const customVoIPTypesWithActors = [ + [resident1VoIPType, actors[0]], + [resident2VoIPType, actors[1]], + [resident3VoIPType, actors[2]], + ] + + for (const [customVoIPType, actor] of customVoIPTypesWithActors) { + await createTestCustomValue(serviceUser, { id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID }, organization, { + sourceType: 'B2BApp', + data: String(customVoIPType), + itemId: actor.contact.id, + sourceId: b2bApp.id, + isUniquePerObject: true, + }) + } + + const dataAttrs = { + callId: faker.datatype.uuid(), + b2cAppCallData: { + B2CAppContext: faker.random.alphaNumeric(10), + }, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [ + { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + ], + stunServers: [faker.internet.ip(), faker.internet.ip()], + voipType: normalVoIPType, + codec: 'vp8', + }, + } + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + callData: dataAttrs, + }) + expect(result.verifiedContactsCount).toBe(residentsCount) + expect(result.createdMessagesCount).toBe(residentsCount) + expect(result.erroredMessagesCount).toBe(0) + + const createdMessages = await Message.getAll(admin, { + user: { id_in: actors.map(actor => actor.user.id) }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: residentsCount }) + + expect(createdMessages).toHaveLength(residentsCount) + expect([...new Set(createdMessages.map(msg => msg.user))]).toHaveLength(residentsCount) + + const createdMessagesByResident = createdMessages.reduce((byResident, msg) => { + byResident[msg.meta.data.residentId] = msg + return byResident + }, {}) + expect(Object.keys(createdMessagesByResident)).toEqual(expect.arrayContaining(actors.map(actor => actor.resident.id))) + + + const defaultMetaDataFields = { + B2CAppId: b2cApp.id, + B2CAppName: b2cApp.name, + callId: dataAttrs.callId, + } + const defaultMetaDataB2CAppCallDataFields = { + ...defaultMetaDataFields, + B2CAppContext: dataAttrs.b2cAppCallData.B2CAppContext, + } + const defaultMetaDataNativeCallDataFields = { + ...defaultMetaDataFields, + voipAddress: dataAttrs.nativeCallData.voipAddress, + voipLogin: dataAttrs.nativeCallData.voipLogin, + voipPassword: dataAttrs.nativeCallData.voipPassword, + voipDtfmCommand: dataAttrs.nativeCallData.voipPanels[0].dtfmCommand, + voipPanels: dataAttrs.nativeCallData.voipPanels, + stun: dataAttrs.nativeCallData.stunServers[0], + stunServers: dataAttrs.nativeCallData.stunServers, + codec: dataAttrs.nativeCallData.codec, + } + + const normalVoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === String(normalVoIPType)) + try { + expect(normalVoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('normalVoIPTypeMessages fail') + throw err + } + expect(normalVoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataNativeCallDataFields, + residentId: actors[3].resident.id, + voipType: String(normalVoIPType), + }), + }), + })) + + const resident1VoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === String(resident1VoIPType)) + try { + expect(resident1VoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('resident1VoIPTypeMessages fail') + throw err + } + expect(resident1VoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataNativeCallDataFields, + residentId: actors[0].resident.id, + voipType: String(resident1VoIPType), + }), + }), + })) + + const resident2VoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === String(resident2VoIPType)) + try { + expect(resident2VoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('resident2VoIPTypeMessages fail') + throw err + } + expect(resident2VoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataNativeCallDataFields, + residentId: actors[1].resident.id, + voipType: String(resident2VoIPType), + }), + }), + })) + + const b2cAppVoIPTypeMessages = createdMessages.filter(msg => !!msg.meta.data.B2CAppContext) + expect(b2cAppVoIPTypeMessages).toHaveLength(1) + try { + expect(b2cAppVoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('b2cAppVoIPTypeMessages fail') + throw err + } + expect(b2cAppVoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataB2CAppCallDataFields, + residentId: actors[2].resident.id, + }), + }), + })) + + }) + + test('forces b2cApp call when CustomValue.data is not a digit', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const serviceUser = await makeClientWithServiceUser() + const [b2bApp] = await createTestB2BApp(admin) + await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) + const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) + await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) + + await createTestCustomValue(serviceUser, { id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID }, organization, { + sourceType: 'B2BApp', + data: faker.random.alphaNumeric(8), + itemId: actor.contact.id, + sourceId: b2bApp.id, + isUniquePerObject: true, + }) + + await createTestAppMessageSetting(admin, { + b2cApp, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + numberOfNotificationInWindow: 10, + }) + + const callData = { + callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: faker.random.alphaNumeric(10) }, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [{ dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], + stunServers: [faker.internet.ip()], + voipType: 1, + codec: 'vp8', + }, + } + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName, + unitType, + callData, + }) + + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(1) + expect(result.erroredMessagesCount).toBe(0) + + const [createdMessage] = await Message.getAll(admin, { + user: { id: actor.user.id }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: 1 }) + + expect(createdMessage).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + B2CAppContext: callData.b2cAppCallData.B2CAppContext, + residentId: actor.resident.id, + }), + }), + })) + expect(createdMessage.meta.data.voipAddress).toBeUndefined() + expect(createdMessage.meta.data.voipType).toBeUndefined() + + // But if there is no data for B2C call, then just send native one + delete callData.b2cAppCallData + const [anotherResult] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName, + unitType, + callData, + }) + expect(anotherResult.verifiedContactsCount).toBe(1) + expect(anotherResult.createdMessagesCount).toBe(1) + expect(anotherResult.erroredMessagesCount).toBe(0) + + const [anotherCreatedMessage] = await Message.getAll(admin, { + user: { id: actor.user.id }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: 1, sortBy: ['createdAt_DESC'] }) + + expect(anotherCreatedMessage).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + voipType: String(callData.nativeCallData.voipType), + residentId: actor.resident.id, + }), + }), + })) + expect(anotherCreatedMessage.meta.data.B2CAppContext).toBeUndefined() + }) + + test(`maps CustomValue.data="${DEFAULT_VOIP_TYPE}" to legacy voipType "${MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY}"`, async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const serviceUser = await makeClientWithServiceUser() + const [b2bApp] = await createTestB2BApp(admin) + await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) + const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) + await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) + + await createTestCustomValue(serviceUser, { id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID }, organization, { + sourceType: 'B2BApp', + data: String(DEFAULT_VOIP_TYPE), + itemId: actor.contact.id, + sourceId: b2bApp.id, + isUniquePerObject: true, + }) + + const callData = { + callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: faker.random.alphaNumeric(10) }, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [{ dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], + stunServers: [faker.internet.ip()], + voipType: 1, + codec: 'vp8', + }, + } + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName, + unitType, + callData, + }) + + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(1) + expect(result.erroredMessagesCount).toBe(0) + + const [createdMessage] = await Message.getAll(admin, { + user: { id: actor.user.id }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: 1 }) + + expect(createdMessage).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + residentId: actor.resident.id, + voipType: MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, + voipAddress: callData.nativeCallData.voipAddress, + }), + }), + })) + }) + + test('uses native call voipType when CustomValue is absent', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) + + const nativeVoIPType = 3 + const callData = { + callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: faker.random.alphaNumeric(10) }, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [{ dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], + stunServers: [faker.internet.ip()], + voipType: nativeVoIPType, + codec: 'vp8', + }, + } + + const [result] = await sendVoIPStartMessageByTestClient(admin, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName, + unitType, + callData, + }) + + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(1) + expect(result.erroredMessagesCount).toBe(0) + + const [createdMessage] = await Message.getAll(admin, { + user: { id: actor.user.id }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: 1 }) + + expect(createdMessage).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + residentId: actor.resident.id, + voipType: String(nativeVoIPType), + voipAddress: callData.nativeCallData.voipAddress, + }), + }), + })) + expect(createdMessage.meta.data.B2CAppContext).toBeUndefined() + }) + + }) + }) + +}) \ No newline at end of file diff --git a/apps/condo/domains/notification/constants/constants.js b/apps/condo/domains/notification/constants/constants.js index cf06f14af07..c6913fa3b81 100644 --- a/apps/condo/domains/notification/constants/constants.js +++ b/apps/condo/domains/notification/constants/constants.js @@ -639,6 +639,8 @@ const MESSAGE_META = { voipLogin: { required: false }, voipPassword: { required: false }, voipDtfmCommand: { required: false }, + voipPanels: { required: false }, + stunServers: { required: false }, stun: { required: false }, codec: { required: false }, }, diff --git a/apps/condo/schema.graphql b/apps/condo/schema.graphql index 44a81a42dc7..d7aef5400f6 100644 --- a/apps/condo/schema.graphql +++ b/apps/condo/schema.graphql @@ -93757,6 +93757,11 @@ input SendVoIPStartMessageDataForCallHandlingByNative { """Preferred codec (usually vp8)""" codec: String + + """ + Type of the native client to handle call. Values defined in mobile app. "0" used as legacy "sip" voipType for backwards compatibility. + """ + voipType: Int } input SendVoIPStartMessageData { @@ -93774,7 +93779,6 @@ input SendVoIPStartMessageData { """ If you want mobile app to handle call (without your B2CApp), provide this argument. If "b2cAppCallData" and "nativeCallData" are provided together, native call is prioritized. - If data is incorrect, mobile app will fallback to b2c app call handling with "b2cAppCallData" """ nativeCallData: SendVoIPStartMessageDataForCallHandlingByNative } From d87acf05a961b4e4b5ff96e4d6703f412241367c Mon Sep 17 00:00:00 2001 From: YEgorLu Date: Tue, 21 Apr 2026 17:43:40 +0500 Subject: [PATCH 5/6] fix(condo): DOMA-12905 after review fixes --- .../access/SendVoIPStartMessageService.js | 7 +- apps/condo/domains/miniapp/constants.js | 8 +- .../schema/B2CAppAccessRightSet.test.js | 62 +- .../schema/SendVoIPStartMessageService.js | 626 +++++++++--------- .../SendVoIPStartMessageService.test.js | 486 +++++++++++--- .../sendVoIPStartMessageService.spec.js | 513 -------------- .../utils/b2cAppServiceUserAccess/config.js | 2 +- .../b2cAppServiceUserAccess/server.utils.js | 6 +- .../property/utils/serverSchema/helpers.js | 18 + ...canexecutesendvoipstartmessage_and_more.js | 48 ++ apps/condo/schema.graphql | 22 +- apps/condo/schema.ts | 27 +- 12 files changed, 917 insertions(+), 908 deletions(-) delete mode 100644 apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js create mode 100644 apps/condo/migrations/20260420183110-0525_b2cappaccessrightset_canexecutesendvoipstartmessage_and_more.js diff --git a/apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js index 916053e02f6..940780522f7 100644 --- a/apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js +++ b/apps/condo/domains/miniapp/access/SendVoIPStartMessageService.js @@ -1,14 +1,17 @@ const { throwAuthenticationError } = require('@open-condo/keystone/apolloErrorFormatter') +const { canExecuteServiceAsB2CAppServiceUser } = require('@condo/domains/miniapp/utils/b2cAppServiceUserAccess/server.utils') const { SERVICE } = require('@condo/domains/user/constants/common') -async function canSendVoIPStartMessage ({ args: { data }, authentication: { item: user } }) { +async function canSendVoIPStartMessage (args) { + const { authentication: { item: user } } = args + if (!user) return throwAuthenticationError() if (user.deletedAt) return false if (user.isAdmin) return true if (user.type === SERVICE) { - // TODO(YEgorLu): add access to B2C Service User by B2CAppProperty + return await canExecuteServiceAsB2CAppServiceUser(args) } return false diff --git a/apps/condo/domains/miniapp/constants.js b/apps/condo/domains/miniapp/constants.js index e3de8c3ba6d..03a0a9bbf72 100644 --- a/apps/condo/domains/miniapp/constants.js +++ b/apps/condo/domains/miniapp/constants.js @@ -133,8 +133,8 @@ const ACCESS_TOKEN_SESSION_ID_PREFIX = `${Buffer.from('b2bAccessToken').toString const ACCESS_TOKEN_UPDATE_MANY_CHUNK_SIZE = 500 -const MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY = 'sip' // without this constant mobile app will not try to make native call -const DEFAULT_VOIP_TYPE = 0 +const NATIVE_VOIP_TYPE = 'sip' +const B2C_APP_VOIP_TYPE = 'b2cApp' module.exports = { ALL_APPS_CATEGORY, @@ -214,6 +214,6 @@ module.exports = { CALL_DATA_NOT_PROVIDED_ERROR, - MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, - DEFAULT_VOIP_TYPE, + NATIVE_VOIP_TYPE, + B2C_APP_VOIP_TYPE, } \ No newline at end of file diff --git a/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js b/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js index 31d200aaa53..463ac8b0c49 100644 --- a/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js +++ b/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js @@ -4,7 +4,7 @@ const { faker } = require('@faker-js/faker') -const { makeLoggedInAdminClient, makeClient, UUID_RE } = require('@open-condo/keystone/test.utils') +const { makeLoggedInAdminClient, makeClient, UUID_RE, expectToThrowAccessDeniedErrorToResult } = require('@open-condo/keystone/test.utils') const { expectToThrowAuthenticationErrorToObj, expectToThrowAuthenticationErrorToObjects, expectToThrowAccessDeniedErrorToObj, expectToThrowAccessDeniedErrorToObjects, @@ -12,12 +12,14 @@ const { -const { B2CAppAccessRightSet, createTestB2CAppAccessRightSet, updateTestB2CAppAccessRightSet, createTestB2CAppProperty, updateTestB2CAppProperty, B2CAppProperty } = require('@condo/domains/miniapp/utils/testSchema') +const { B2CAppAccessRightSet, createTestB2CAppAccessRightSet, updateTestB2CAppAccessRightSet, createTestB2CAppProperty, updateTestB2CAppProperty, B2CAppProperty, sendVoIPStartMessageByTestClient, updateTestB2CAppAccessRight } = require('@condo/domains/miniapp/utils/testSchema') const { createTestB2CApp, createTestB2CAppAccessRight } = require('@condo/domains/miniapp/utils/testSchema') const { buildFakeAddressAndMeta } = require('@condo/domains/property/utils/testSchema/factories') const { makeClientWithNewRegisteredAndLoggedInUser, makeClientWithSupportUser } = require('@condo/domains/user/utils/testSchema') const { makeClientWithServiceUser } = require('@condo/domains/user/utils/testSchema') +const { FLAT_UNIT_TYPE } = require('../../property/constants/common') + describe('B2CAppAccessRightSet', () => { let admin @@ -304,13 +306,14 @@ describe('B2CAppAccessRightSet', () => { let user let b2cApp let serviceUser + let b2cAccessRight beforeEach(async () => { support = await makeClientWithSupportUser() user = await makeClientWithNewRegisteredAndLoggedInUser() serviceUser = await makeClientWithServiceUser(); - [b2cApp] = await createTestB2CApp(admin) - await createTestB2CAppAccessRight(admin, serviceUser.user, b2cApp) + [b2cApp] = await createTestB2CApp(admin); + [b2cAccessRight] = await createTestB2CAppAccessRight(admin, serviceUser.user, b2cApp) }) test('B2CAppAccessRightSet', async () => { @@ -418,6 +421,57 @@ describe('B2CAppAccessRightSet', () => { expect(foundB2CAppProperty.id).toEqual(createdB2CAppProperty.id) }) + + test('SendVoIPStartMessageService', async () => { + const args = { + app: { id: b2cApp.id }, + addressKey: faker.datatype.uuid(), + unitName: faker.random.alphaNumeric(8), + unitType: FLAT_UNIT_TYPE, + callData: { + callId: faker.random.alphaNumeric(8), + b2cAppCallData: { + B2CAppContext: faker.random.alphaNumeric(8), + }, + }, + } + + // Can't without access right set + await expectToThrowAccessDeniedErrorToResult(async () => { + await sendVoIPStartMessageByTestClient(serviceUser, args) + }) + + // Can't with access right set and without b2c app property + const [rightSet] = await createTestB2CAppAccessRightSet(support, b2cApp, { canExecuteSendVoIPStartMessage: true }) + await updateTestB2CAppAccessRight(support, b2cAccessRight.id, { accessRightSet: { connect: { id: rightSet.id } } }) + await expectToThrowAccessDeniedErrorToResult(async () => { + await sendVoIPStartMessageByTestClient(serviceUser, args) + }) + + // Can with access right set and b2c app property + const [b2cAppProperty] = await createTestB2CAppProperty(serviceUser, b2cApp) + args.addressKey = b2cAppProperty.addressKey + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, args) + expect(result).toEqual(expect.objectContaining({ + verifiedContactsCount: expect.any(Number), + createdMessagesCount: expect.any(Number), + erroredMessagesCount: expect.any(Number), + })) + + // Can't with access right and another b2c app proeprty + const [anotherB2CApp] = await createTestB2CApp(admin) + const [anotherB2CAppProperty] = await createTestB2CAppProperty(admin, anotherB2CApp) + args.addressKey = anotherB2CAppProperty.addressKey + await expectToThrowAccessDeniedErrorToResult(async () => { + await sendVoIPStartMessageByTestClient(serviceUser, args) + }) + + // Can't for another app + args.app.id = anotherB2CApp.id + await expectToThrowAccessDeniedErrorToResult(async () => { + await sendVoIPStartMessageByTestClient(serviceUser, args) + }) + }) }) }) diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js index c02300341c6..567ac023bcb 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js @@ -1,7 +1,6 @@ const get = require('lodash/get') const omit = require('lodash/omit') -const conf = require('@open-condo/config') const { GQLError, GQLErrorCode: { BAD_USER_INPUT } } = require('@open-condo/keystone/errors') const { getLogger } = require('@open-condo/keystone/logging') const { checkDvAndSender } = require('@open-condo/keystone/plugins/dvAndSender') @@ -15,17 +14,19 @@ const { DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, DEFAULT_NOTIFICATION_WINDOW_DURATION_IN_SECONDS, CALL_DATA_NOT_PROVIDED_ERROR, - MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, DEFAULT_VOIP_TYPE, + NATIVE_VOIP_TYPE, + B2C_APP_VOIP_TYPE, } = require('@condo/domains/miniapp/constants') -const { B2CAppProperty } = require('@condo/domains/miniapp/utils/serverSchema') +const { B2CAppProperty, CustomValue } = require('@condo/domains/miniapp/utils/serverSchema') const { setCallStatus, CALL_STATUS_START_SENT } = require('@condo/domains/miniapp/utils/voip') const { MESSAGE_META, VOIP_INCOMING_CALL_MESSAGE_TYPE, MESSAGE_SENDING_STATUS, } = require('@condo/domains/notification/constants/constants') const { sendMessage } = require('@condo/domains/notification/utils/serverSchema') -const { FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE } = require('@condo/domains/property/constants/common') -const { RESIDENT } = require('@condo/domains/user/constants/common') +const { UNIT_TYPES } = require('@condo/domains/property/constants/common') +const { getOldestNonDeletedProperty } = require('@condo/domains/property/utils/serverSchema/helpers') +const { Resident } = require('@condo/domains/resident/utils/serverSchema') const { RedisGuard } = require('@condo/domains/user/utils/serverSchema/guards') const CACHE_TTL = { @@ -33,16 +34,7 @@ const CACHE_TTL = { [VOIP_INCOMING_CALL_MESSAGE_TYPE]: 2, } -/** - * If debug app is set and debug app settings are configured, then user can send push messages without creating B2CApp first. - * This is useful for testing and development, but it should be turned off on production - */ -const DEBUG_APP_ID = conf.MINIAPP_PUSH_MESSAGE_DEBUG_APP_ID -const DEBUG_APP_ENABLED = !!DEBUG_APP_ID -const DEBUG_APP_SETTINGS = DEBUG_APP_ENABLED ? Object.freeze(JSON.parse(conf.MINIAPP_PUSH_MESSAGE_DEBUG_APP_SETTINGS)) : {} -const ALLOWED_UNIT_TYPES = [FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE] -const VOIP_TYPE_CUSTOM_FIELD_ID = conf.VOIP_TYPE_CUSTOM_FIELD_ID - +const POSSIBLE_CUSTOM_FIELD_NAMES = Object.keys(get(MESSAGE_META[VOIP_INCOMING_CALL_MESSAGE_TYPE], 'data', {})).filter(key => key.startsWith('voip')) const redisGuard = new RedisGuard() const logger = getLogger() @@ -50,7 +42,7 @@ const logger = getLogger() const SERVICE_NAME = 'sendVoIPStartMessage' const ERRORS = { PROPERTY_NOT_FOUND: { - query: SERVICE_NAME, + mutation: SERVICE_NAME, variable: ['data', 'addressKey'], code: BAD_USER_INPUT, type: PROPERTY_NOT_FOUND_ERROR, @@ -82,25 +74,317 @@ const ERRORS = { }, } -const B2CAPP_VOIP_TYPE = 'b2cApp' +function logInfo ({ b2cAppId, callId, stats, err }) { + logger.info({ msg: `${SERVICE_NAME} stats`, entityName: 'B2CApp', entityId: b2cAppId, data: { callId, stats }, err: err }) +} + +async function checkLimits ({ context, b2cAppId, logContext }) { + const appSettings = await getByCondition('AppMessageSetting', { + b2cApp: { id: b2cAppId }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + deletedAt: null, + }) + + const searchKey = `${VOIP_INCOMING_CALL_MESSAGE_TYPE}-${b2cAppId}` + const ttl = CACHE_TTL[VOIP_INCOMING_CALL_MESSAGE_TYPE] || CACHE_TTL['DEFAULT'] + + try { + await redisGuard.checkCustomLimitCounters( + `${SERVICE_NAME}-${searchKey}`, + appSettings?.notificationWindowSize ?? ttl, + appSettings?.numberOfNotificationInWindow ?? DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, + context, + ) + } catch (err) { + logContext.logInfoStats.step = 'check limits' + logInfo({ ...logContext, err: err }) + throw err + } +} + +function getInitialLogContext ({ addressKey, unitName, unitType, app, callData }) { + return { + logInfoStats: { + step: 'init', + addressKey, + unitName, + unitType, + verifiedContactsCount: 0, + residentsCount: 0, + verifiedResidentsCount: 0, + createdMessagesCount: 0, + erroredMessagesCount: 0, + createMessageErrors: [], + isStatusCached: false, + isPropertyFound: false, + isAppFound: false, + }, + b2cAppId: app.id, + callId: callData.callId, + } +} + +async function sendMessageToActor ({ + context, actor, + customVoIPValuesByContactId, + sender, dv, b2cApp: { id: b2cAppId, name: b2cAppName }, + callData, +}) { + const { resident, contact, user } = actor + const customVoIPValues = customVoIPValuesByContactId[contact.id] || {} + + // NOTE(YEgorLu): as in domains/notification/constants/config for VOIP_INCOMING_CALL_MESSAGE_TYPE + let preparedDataArgs = { + B2CAppId: b2cAppId, + B2CAppName: b2cAppName, + residentId: resident.id, + callId: callData.callId, + } + + const needToPasteB2CAppCallData = !callData.nativeCallData || (callData.b2cAppCallData && customVoIPValues.voipType === B2C_APP_VOIP_TYPE) + + if (!needToPasteB2CAppCallData) { + let voipType = customVoIPValues.voipType || NATIVE_VOIP_TYPE + // CustomValue says to use B2C_APP_VOIP_TYPE, but we have no b2cAppCallData + if (voipType === B2C_APP_VOIP_TYPE) { + voipType = NATIVE_VOIP_TYPE + } + + preparedDataArgs = { + ...preparedDataArgs, + voipType: voipType, + voipAddress: callData.nativeCallData.voipAddress, + voipLogin: callData.nativeCallData.voipLogin, + voipPassword: callData.nativeCallData.voipPassword, + voipDtfmCommand: callData.nativeCallData.voipPanels?.[0]?.dtmfCommand, + voipPanels: callData.nativeCallData.voipPanels, + stunServers: callData.nativeCallData.stunServers, + stun: callData.nativeCallData.stunServers?.[0], + codec: callData.nativeCallData.codec, + ...omit(customVoIPValues, 'voipType'), + } + } else { + preparedDataArgs = { + ...preparedDataArgs, + B2CAppContext: callData.b2cAppCallData.B2CAppContext, + } + } + + const requiredMetaData = get(MESSAGE_META[VOIP_INCOMING_CALL_MESSAGE_TYPE], 'data', {}) + const metaData = Object.fromEntries( + Object.keys(requiredMetaData).map((key) => [key, preparedDataArgs[key]]) + ) + + const messageAttrs = { + sender, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + to: { user: { id: user.id } }, + meta: { + dv, + title: '', // NOTE(YEgorLu): title and body are rewritten by translations for push type + body: '', + data: metaData, + }, + } -function logInfo ({ b2cAppId, callId, stats, errors }) { - logger.info({ msg: `${SERVICE_NAME} stats`, entityName: 'B2CApp', entityId: b2cAppId, data: { callId, stats }, err: errors }) + const result = await sendMessage(context, messageAttrs) + return { actor, result } +} + +async function getActors ({ context, logContext, addressKey, unitName, unitType }) { + let actors = [] + + const oldestProperty = await getOldestNonDeletedProperty({ addressKey }) + logContext.logInfoStats.step = 'find property' + logContext.logInfoStats.propertyFound = !!oldestProperty + + if (!oldestProperty?.id) { + logInfo(logContext) + return { + actors, + earlyReturnValue: { + verifiedContactsCount: 0, + createdMessagesCount: 0, + erroredMessagesCount: 0, + }, + } + } + + const allVerifiedContactsOnUnit = await find('Contact', { + property: { id: oldestProperty.id, deletedAt: null }, + unitName_i: unitName, + unitType: unitType, + isVerified: true, + deletedAt: null, + }) + logContext.logInfoStats.step = 'find contacts' + logContext.logInfoStats.contactsCount = allVerifiedContactsOnUnit.length + + if (!allVerifiedContactsOnUnit.length) { + logInfo(logContext) + return { + actors, + earlyReturnValue: { + verifiedContactsCount: 0, + createdMessagesCount: 0, + erroredMessagesCount: 0, + }, + } + } + + const allResidentsOnUnit = await Resident.getAll(context, { + addressKey, + unitName, + unitType, + deletedAt: null, + }, 'id user { id phone }') + logContext.logInfoStats.step = 'find residents' + logContext.logInfoStats.residentsCount = allResidentsOnUnit.length + + if (!allResidentsOnUnit.length) { + logInfo(logContext) + return { + actors, + earlyReturnValue: { + verifiedContactsCount: allVerifiedContactsOnUnit.length, + createdMessagesCount: 0, + erroredMessagesCount: 0, + }, + } + } + + const actorsByPhone = {} + + for (const contact of allVerifiedContactsOnUnit) { + const actor = { contact, resident: null, user: null } + actors.push(actor) + actorsByPhone[actor.contact.phone] = actor + } + + for (const resident of allResidentsOnUnit) { + const phone = resident.user.phone + if (!phone || !actorsByPhone[phone]) continue + actorsByPhone[phone].resident = resident + actorsByPhone[phone].user = resident.user + } + + actors = actors.filter(actor => !!actor.resident && !!actor.contact && !!actor.user) + + logContext.logInfoStats.step = 'find verified residents' + logContext.logInfoStats.contactsCount = actors.length + + return { + actors, + earlyReturnValue: { + verifiedContactsCount: allVerifiedContactsOnUnit.length, + createdMessagesCount: 0, + erroredMessagesCount: 0, + }, + } +} + +async function verifyApp ({ context, logContext, addressKey, app }) { + + const b2cAppId = app.id + + const b2cAppProperty = await B2CAppProperty.getOne(context, { + app: { id: b2cAppId, deletedAt: null }, + addressKey: addressKey, + deletedAt: null, + }, 'id app { id name }') + + if (!b2cAppProperty) { + logInfo(logContext) + throw new GQLError(ERRORS.PROPERTY_NOT_FOUND, context) + } + logContext.logInfoStats.isPropertyFound = true + + if (!b2cAppProperty.app) { + logInfo(logContext) + throw new GQLError(ERRORS.APP_NOT_FOUND, context) + } + logContext.logInfoStats.isAppFound = true + + return { b2cAppId, b2cAppName: b2cAppProperty.app.name } +} + +async function getCustomVoIPValuesByContacts ({ context, contactIds }) { + const customVoIPValues = await CustomValue.getAll(context, { + customField: { name_in: POSSIBLE_CUSTOM_FIELD_NAMES, deletedAt: null }, + itemId_in: contactIds, + deletedAt: null, + }, 'id itemId data customField { name }') + return customVoIPValues.reduce((byContactId, customValue) => { + if (!byContactId[customValue.itemId]) byContactId[customValue.itemId] = {} + byContactId[customValue.itemId][customValue.customField.name] = customValue.data + return byContactId + }, {}) +} + +function parseSendMessageResults ({ logContext, sendMessagePromisesResults }) { + const sendMessageStats = sendMessagePromisesResults.map(promiseResult => { + if (promiseResult.status === 'rejected') { + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(promiseResult.reason) + return { error: promiseResult.reason } + } + const { actor: { resident }, result } = promiseResult.value + + if (result.isDuplicateMessage) { + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(`${resident.id} duplicate message`) + return { error: `${resident.id} duplicate message` } + } + if (result.status !== MESSAGE_SENDING_STATUS) { + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(`${resident.id} invalid status for some reason`) + return { error: `${resident.id} invalid status for some reason` } + } + logContext.logInfoStats.createdMessagesCount++ + return result + }) + + for (const messageStat of sendMessageStats) { + if (messageStat.error) { + logContext.logInfoStats.erroredMessagesCount++ + logContext.logInfoStats.createMessageErrors.push(messageStat.error) + continue + } + logContext.logInfoStats.createdMessagesCount++ + } + + return sendMessageStats +} + +function parseStartingMessagesIdsByUserIdsByMessageResults ({ sendMessagePromisesResults }) { + const startingMessagesIdsByUserIds = {} + for (const promiseResult of sendMessagePromisesResults) { + if (promiseResult.status !== 'fulfilled') { + continue + } + + const { actor: { user }, result } = promiseResult.value + if (result?.id) { + startingMessagesIdsByUserIds[user.id] = result.id + } + } + + return startingMessagesIdsByUserIds } const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageService', { types: [ { access: true, - type: `input VoIPPanel { + type: `input SendVoIPStartMessageVoIPPanelParameters { """ - Dtfm command for panel + Dtmf command used to open the panel """ - dtfmCommand: String! + dtmfCommand: String! """ Name of a panel to be displayed """ - name: String! + name: String }`, }, { @@ -130,7 +414,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer """ Panels and their commands to open. First one must be the main one. Multiple panels are in testing stage right now and may change """ - voipPanels: [VoIPPanel!]! + voipPanels: [SendVoIPStartMessageVoIPPanelParameters!]! """ Stun server urls. Are used to determine device public ip for media streams """ @@ -139,10 +423,6 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer Preferred codec (usually vp8) """ codec: String - """ - Type of the native client to handle call. Values defined in mobile app. "0" used as legacy "${MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY}" voipType for backwards compatibility. - """ - voipType: Int = ${DEFAULT_VOIP_TYPE} }`, }, { @@ -181,7 +461,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer """ Type of unit, same as in Property map """ - unitType: AllowedVoIPMessageUnitType!, + unitType: SendVoIPStartMessageUnitType!, callData: SendVoIPStartMessageData! }`, }, @@ -204,7 +484,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer }, { access: true, - type: `enum AllowedVoIPMessageUnitType { ${ALLOWED_UNIT_TYPES.join('\n')} }`, + type: `enum SendVoIPStartMessageUnitType { ${UNIT_TYPES.join('\n')} }`, }, ], @@ -227,282 +507,38 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer checkDvAndSender(argsData, ERRORS.DV_VERSION_MISMATCH, ERRORS.WRONG_SENDER_FORMAT, context) - const logContext = { - logInfoStats: { - step: 'init', - addressKey, - unitName, - unitType, - verifiedContactsCount: 0, - residentsCount: 0, - verifiedResidentsCount: 0, - createdMessagesCount: 0, - erroredMessagesCount: 0, - createMessageErrors: [], - isStatusCached: false, - isPropertyFound: false, - isAppFound: false, - }, - b2cAppId: app.id, - callId: callData.callId, - } + const logContext = getInitialLogContext(argsData) // 1) Check B2CApp and B2CAppProperty - const b2cAppId = app.id - - const [b2cAppProperty] = await B2CAppProperty.getAll(context, { - app: { id: b2cAppId, deletedAt: null }, - addressKey: addressKey, - deletedAt: null, - }, 'id app { id name }', { first: 1 }) - - if (!b2cAppProperty) { - logInfo(logContext) - throw new GQLError(ERRORS.PROPERTY_NOT_FOUND, context) - } - logContext.logInfoStats.isPropertyFound = true - - if (!b2cAppProperty.app) { - logInfo(logContext) - throw new GQLError(ERRORS.APP_NOT_FOUND, context) - } - logContext.logInfoStats.isAppFound = true - - const b2cAppName = b2cAppProperty.app.name - - let actors = [] - - // 2) Find Users - const verifiedContactsOnUnit = await find('Contact', { - property: { addressKey: addressKey, deletedAt: null }, - unitName_i: unitName, - unitType: unitType, - isVerified: true, - deletedAt: null, - }) - for (const contact of verifiedContactsOnUnit) { - actors.push({ contact }) - } - const actorByPhone = actors.reduce((byPhone, actor) => { - byPhone[actor.contact.phone] = actor - return byPhone - }, {}) - logContext.logInfoStats.step = 'find verified contacts' - logContext.logInfoStats.verifiedContactsCount = verifiedContactsOnUnit.length - - if (!verifiedContactsOnUnit?.length) { - logInfo(logContext) - return { - verifiedContactsCount: 0, - createdMessagesCount: 0, - erroredMessagesCount: 0, - } - } - - const residentsOnUnit = await find('Resident', { - unitName_i: unitName, - unitType: unitType, - deletedAt: null, - property: { id_in: [...new Set(actors.map(actor => actor.contact.property))] }, - }) - logContext.logInfoStats.step = 'find residents' - logContext.logInfoStats.residentsCount = residentsOnUnit.length - - if (!residentsOnUnit.length) { - logInfo(logContext) - return { - verifiedContactsCount: verifiedContactsOnUnit.length, - createdMessagesCount: 0, - erroredMessagesCount: 0, - } - } - - // NOTE(YEgorLu): doing same as Resident.isVerifiedByManagingCompany virtual field - const usersOfContacts = await find('User', { - phone_in: [...new Set(actors.map(actor => actor.contact.phone))], - deletedAt: null, - type: RESIDENT, - isPhoneVerified: true, - }) - for (const user of usersOfContacts) { - const actor = actorByPhone[user.phone] - if (actor) { - actor.user = user - } - } - actors = actors.filter(actor => !!actor.user) - - const actorsByUserIds = actors.reduce((byId, actor) => { - byId[actor.user.id] = actor - return byId - }, {}) - - for (const resident of residentsOnUnit) { - const actor = actorsByUserIds[resident.user] - if (actor) { - actor.resident = resident - } - } - actors = actors.filter(actor => !!actor.resident) - logContext.logInfoStats.step = 'verify residents' - logContext.logInfoStats.verifiedResidentsCount = actors.length - - if (!actors?.length) { - logInfo(logContext) - return { - verifiedContactsCount: verifiedContactsOnUnit.length, - createdMessagesCount: 0, - erroredMessagesCount: 0, - } - } - - let appSettings + const { b2cAppId, b2cAppName } = await verifyApp({ context, logContext, addressKey, app }) - if (!DEBUG_APP_ENABLED || b2cAppId !== DEBUG_APP_ID) { - appSettings = await getByCondition('AppMessageSetting', { - b2cApp: { id: b2cAppId }, type: VOIP_INCOMING_CALL_MESSAGE_TYPE, deletedAt: null, - }) - } - - else { - appSettings = { ...DEBUG_APP_SETTINGS } + // 2) Get verified residents + const { actors, earlyReturnValue } = await getActors({ context, logContext, addressKey, unitName, unitType }) + if (!actors.length) { + return earlyReturnValue } + const { verifiedContactsCount } = earlyReturnValue - // 3) Create Messages - - const searchKey = `${VOIP_INCOMING_CALL_MESSAGE_TYPE}-${b2cAppId}` - const ttl = CACHE_TTL[VOIP_INCOMING_CALL_MESSAGE_TYPE] || CACHE_TTL['DEFAULT'] - - try { - await redisGuard.checkCustomLimitCounters( - `${SERVICE_NAME}-${searchKey}`, - get(appSettings, 'notificationWindowSize') ?? ttl, - get(appSettings, 'numberOfNotificationInWindow') ?? DEFAULT_NOTIFICATION_WINDOW_MAX_COUNT, - context, - ) - } catch (err) { - logContext.logInfoStats.step = 'check limits' - logInfo({ ...logContext, errors: err }) - throw err - } + // 3) Check limits + await checkLimits({ context, logContext, b2cAppId }) - let voipTypeCustomValuesByContacts = {} - if (VOIP_TYPE_CUSTOM_FIELD_ID) { - const voipTypeCustomValues = await find('CustomValue', { - customField: { id: VOIP_TYPE_CUSTOM_FIELD_ID }, - itemId_in: actors.map(actor => actor.contact.id), - deletedAt: null, - }) - voipTypeCustomValuesByContacts = voipTypeCustomValues - .filter(customValue => typeof customValue.data === 'string') - .reduce((acc, customValue) => { - const isDataInProperFormat = /^\d+$/.test(customValue.data) - // if something is wrong put B2CAPP_VOIP_TYPE to force b2cAppCallData - acc[customValue.itemId] = isDataInProperFormat ? customValue.data : B2CAPP_VOIP_TYPE - return acc - }, {}) - } - - const startingMessagesIdsByUserIds = {} + const customVoIPValuesByContactId = await getCustomVoIPValuesByContacts({ context, contactIds: [...new Set(actors.map(actor => actor.contact.id))] }) + + // 4) Send messages /** @type {Array>} */ const sendMessagePromises = actors - .filter(actor => !!actor.resident && !!actor.contact && !!actor.user) - .map(async ({ contact, resident, user }) => { - const customVoIPType = voipTypeCustomValuesByContacts[contact.id] - - // NOTE(YEgorLu): as in domains/notification/constants/config for VOIP_INCOMING_CALL_MESSAGE_TYPE - let preparedDataArgs = { - B2CAppId: b2cAppId, - B2CAppName: b2cAppName, - residentId: resident.id, - callId: callData.callId, - } - - const needToPasteB2CAppCallData = !callData.nativeCallData || (callData.b2cAppCallData && customVoIPType === B2CAPP_VOIP_TYPE) - - if (!needToPasteB2CAppCallData) { - let voipType = customVoIPType || callData.nativeCallData.voipType || DEFAULT_VOIP_TYPE - // CustomValue says to use B2CAPP_VOIP_TYPE, but we have no b2cAppCallData - if (voipType === B2CAPP_VOIP_TYPE) { - voipType = callData.nativeCallData.voipType || DEFAULT_VOIP_TYPE - } - - preparedDataArgs = { - ...preparedDataArgs, - voipType: String(String(voipType) === String(DEFAULT_VOIP_TYPE) ? MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY : voipType), - voipAddress: callData.nativeCallData.voipAddress, - voipLogin: callData.nativeCallData.voipLogin, - voipPassword: callData.nativeCallData.voipPassword, - voipDtfmCommand: callData.nativeCallData.voipPanels?.[0]?.dtfmCommand, - voipPanels: callData.nativeCallData.voipPanels, - stunServers: callData.nativeCallData.stunServers, - stun: callData.nativeCallData.stunServers?.[0], - codec: callData.nativeCallData.codec, - } - } else { - preparedDataArgs = { - ...preparedDataArgs, - B2CAppContext: callData.b2cAppCallData.B2CAppContext, - } - } - - const requiredMetaData = get(MESSAGE_META[VOIP_INCOMING_CALL_MESSAGE_TYPE], 'data', {}) - const metaData = Object.fromEntries( - Object.keys(requiredMetaData).map((key) => [key, preparedDataArgs[key]]) - ) - - const messageAttrs = { - sender, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - to: { user: { id: user.id } }, - meta: { - dv, - title: '', // NOTE(YEgorLu): title and body are rewritten by translations for push type - body: '', - data: metaData, - }, - } - - const res = await sendMessage(context, messageAttrs) - if (res?.id) { - startingMessagesIdsByUserIds[user.id] = res.id - } - return { resident, result: res } + .map((actor) => { + return sendMessageToActor({ + context, actor, customVoIPValuesByContactId, + dv, sender, callData, b2cApp: { id: b2cAppId, name: b2cAppName }, + }) }) - // 4) Set status in redis - + // 5) Set call status in redis logContext.logInfoStats.step = 'send messages' const sendMessageResults = await Promise.allSettled(sendMessagePromises) - const sendMessageStats = sendMessageResults.map(promiseResult => { - if (promiseResult.status === 'rejected') { - logContext.logInfoStats.erroredMessagesCount++ - logContext.logInfoStats.createMessageErrors.push(promiseResult.reason) - return { error: promiseResult.reason } - } - const { resident, result } = promiseResult.value - if (result.isDuplicateMessage) { - logContext.logInfoStats.erroredMessagesCount++ - logContext.logInfoStats.createMessageErrors.push(`${resident.id} duplicate message`) - return { error: `${resident.id} duplicate message` } - } - if (result.status !== MESSAGE_SENDING_STATUS) { - logContext.logInfoStats.erroredMessagesCount++ - logContext.logInfoStats.createMessageErrors.push(`${resident.id} invalid status for some reason`) - return { error: `${resident.id} invalid status for some reason` } - } - logContext.logInfoStats.createdMessagesCount++ - return result - }) - - for (const messageStat of sendMessageStats) { - if (messageStat.error) { - logContext.logInfoStats.erroredMessagesCount++ - logContext.logInfoStats.createMessageErrors.push(messageStat.error) - continue - } - logContext.logInfoStats.createdMessagesCount++ - } + const sendMessageStats = parseSendMessageResults({ sendMessagePromisesResults: sendMessageResults, logContext }) + const startingMessagesIdsByUserIds = parseStartingMessagesIdsByUserIdsByMessageResults({ sendMessagePromisesResults: sendMessageResults }) if (sendMessageStats.some(stat => !stat.error)) { logContext.logInfoStats.isStatusCached = await setCallStatus({ @@ -520,7 +556,7 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer logInfo(logContext) return { - verifiedContactsCount: verifiedContactsOnUnit.length, + verifiedContactsCount, createdMessagesCount: sendMessageStats.filter(stat => !stat.error).length, erroredMessagesCount: sendMessageStats.filter(stat => !!stat.error).length, } diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js index b5237b9a477..5da615fc8a5 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.test.js @@ -14,21 +14,55 @@ const { createTestB2CApp, sendVoIPStartMessageByTestClient, createTestB2CAppProperty, + createTestB2CAppAccessRight, + createTestB2CAppAccessRightSet, + updateTestB2CAppAccessRight, + createTestB2BApp, + createTestB2BAppContext, + createTestB2BAppAccessRightSet, + createTestB2BAppAccessRight, + createTestCustomValue, + createTestAppMessageSetting, + createTestCustomField, } = require('@condo/domains/miniapp/utils/testSchema') const { getCallStatus, CALL_STATUS_START_SENT } = require('@condo/domains/miniapp/utils/voip') const { VOIP_INCOMING_CALL_MESSAGE_TYPE, } = require('@condo/domains/notification/constants/constants') const { Message } = require('@condo/domains/notification/utils/testSchema') -const { FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE, UNIT_TYPES } = require('@condo/domains/property/constants/common') +const { FLAT_UNIT_TYPE } = require('@condo/domains/property/constants/common') const { makeClientWithResidentAccessAndProperty } = require('@condo/domains/property/utils/testSchema') const { createTestResident } = require('@condo/domains/resident/utils/testSchema') const { makeClientWithSupportUser, makeClientWithNewRegisteredAndLoggedInUser, makeClientWithResidentUser, createTestPhone, + makeClientWithServiceUser, } = require('@condo/domains/user/utils/testSchema') const { ERRORS } = require('./SendVoIPStartMessageService') +const { NATIVE_VOIP_TYPE, B2C_APP_VOIP_TYPE } = require('../constants') + +async function prepareSingleActor ({ admin, organization, property, unitName, unitType }) { + const phone = createTestPhone() + const userClient = await makeClientWithResidentUser({}, { phone }) + const [contact] = await createTestContact(admin, organization, property, { + unitName: unitName, + unitType: unitType, + isVerified: true, + phone: phone, + }) + const [resident] = await createTestResident(admin, userClient.user, property, { + unitName: unitName, + unitType: unitType, + }) + + return { + user: userClient.user, + contact, + resident, + } +} + describe('SendVoIPStartMessageService', () => { let admin @@ -39,6 +73,8 @@ describe('SendVoIPStartMessageService', () => { describe('Access', () => { let b2cApp let property + let serviceUser + let b2cAccessRight beforeAll(async () => { const [testB2CApp] = await createTestB2CApp(admin) @@ -47,6 +83,8 @@ describe('SendVoIPStartMessageService', () => { const { property: testProperty } = await makeClientWithResidentAccessAndProperty() property = testProperty await createTestB2CAppProperty(admin, b2cApp, { address: testProperty.address, addressMeta: testProperty.addressMeta }) + serviceUser = await makeClientWithServiceUser(); + [b2cAccessRight] = await createTestB2CAppAccessRight(admin, serviceUser.user, b2cApp) }) const TEST_CASES = [ @@ -54,7 +92,20 @@ describe('SendVoIPStartMessageService', () => { { name: 'support can\'t', getClient: () => makeClientWithSupportUser(), expectError: expectToThrowAccessDeniedErrorToResult }, { name: 'user can\'t', getClient: () => makeClientWithNewRegisteredAndLoggedInUser(), expectError: expectToThrowAccessDeniedErrorToResult }, { name: 'anonymous can\'t', getClient: () => makeClient(), expectError: expectToThrowAuthenticationErrorToResult }, - // TODO(YEgorLu): Add b2c service user with / without access right set tests after adding it to accesses + { + name: 'service user without access right set and b2c app property can\'t', + getClient: () => serviceUser, + expectError: expectToThrowAccessDeniedErrorToResult, + }, + { + name: 'service user with access right set and b2c app property can', + getClient: async () => { + const [rightSet] = await createTestB2CAppAccessRightSet(admin, b2cApp, { canExecuteSendVoIPStartMessage: true }) + await updateTestB2CAppAccessRight(admin, b2cAccessRight.id, { accessRightSet: { connect: { id: rightSet.id } } }) + return serviceUser + }, + expectError: null, + }, ] test.each(TEST_CASES)('$name', async ({ getClient, expectError }) => { @@ -139,11 +190,28 @@ describe('SendVoIPStartMessageService', () => { }) describe('Logic', () => { - test('successfully sends VoIP start message when all conditions are met', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + let serviceUser + let b2cAppProperty + let organization + let property + let b2cApp + + beforeAll(async () => { + const [testB2CApp] = await createTestB2CApp(admin) + b2cApp = testB2CApp + + const { organization: testOrganization, property: testProperty } = await makeClientWithResidentAccessAndProperty() + organization = testOrganization + property = testProperty; + [b2cAppProperty] = await createTestB2CAppProperty(admin, b2cApp, { address: testProperty.address, addressMeta: testProperty.addressMeta }) + serviceUser = await makeClientWithServiceUser() + const [accessRightSet] = await createTestB2CAppAccessRightSet(admin, b2cApp, { canExecuteSendVoIPStartMessage: true }) + await createTestB2CAppAccessRight(admin, serviceUser.user, b2cApp, { accessRightSet: { connect: { id: accessRightSet.id } } }) + await createTestAppMessageSetting(admin, { b2cApp, numberOfNotificationInWindow: 1000, type: VOIP_INCOMING_CALL_MESSAGE_TYPE }) + }) + + test('successfully sends VoIP start message when all conditions are met', async () => { const unitName = faker.random.alphaNumeric(3) const unitType = FLAT_UNIT_TYPE @@ -154,7 +222,7 @@ describe('SendVoIPStartMessageService', () => { const prepareDataPromises = [] for (let i = 0; i < verifiedContactsCount; i++) { - prepareDataPromises.push((async (admin) => { + prepareDataPromises.push((async (admin, organization, property) => { const phone = createTestPhone() const userClient = await makeClientWithResidentUser({}, { phone }) await createTestContact(admin, organization, property, { @@ -170,20 +238,20 @@ describe('SendVoIPStartMessageService', () => { unitType: unitType, }) } - })(admin)) + })(admin, organization, property)) } for (let i = 0; i < notVerifiedResidentsCount; i++) { - prepareDataPromises.push((async (admin) => { + prepareDataPromises.push((async (admin, property) => { const userClient = await makeClientWithResidentUser() await createTestResident(admin, userClient.user, property, { unitName: unitName, unitType: unitType, }) - })(admin)) + })(admin, property)) } await Promise.all(prepareDataPromises) - const [result] = await sendVoIPStartMessageByTestClient(admin, { + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { app: { id: b2cApp.id }, addressKey: property.addressKey, unitName: unitName, @@ -200,10 +268,6 @@ describe('SendVoIPStartMessageService', () => { }) test('returns zeroish stats when no verified contacts found on unit', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - const unitName = faker.random.alphaNumeric(3) const unitType = FLAT_UNIT_TYPE @@ -218,7 +282,7 @@ describe('SendVoIPStartMessageService', () => { unitType: unitType, }) - const [result] = await sendVoIPStartMessageByTestClient(admin, { + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { app: { id: b2cApp.id }, addressKey: property.addressKey, unitName: unitName, @@ -235,10 +299,6 @@ describe('SendVoIPStartMessageService', () => { }) test('returns zero message attempts when no residents found for verified contacts', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - const unitName = faker.random.alphaNumeric(3) const unitType = FLAT_UNIT_TYPE @@ -253,7 +313,7 @@ describe('SendVoIPStartMessageService', () => { unitType: unitType, }) - const [result] = await sendVoIPStartMessageByTestClient(admin, { + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { app: { id: b2cApp.id }, addressKey: property.addressKey, unitName: unitName, @@ -268,59 +328,9 @@ describe('SendVoIPStartMessageService', () => { expect(result.createdMessagesCount).toBe(0) expect(result.erroredMessagesCount).toBe(0) }) - - const ALLOWED_UNIT_TYPES = [FLAT_UNIT_TYPE, APARTMENT_UNIT_TYPE] - - test(`Includes only ${ALLOWED_UNIT_TYPES.join(' / ')} units`, async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - - const unitName = faker.random.alphaNumeric(3) - - for (const possibleUnitType of UNIT_TYPES) { - await createTestContact(admin, organization, property, { - unitName: unitName, - unitType: possibleUnitType, - isVerified: true, - }) - } - - const mutate = (unitType) => sendVoIPStartMessageByTestClient(admin, { - app: { id: b2cApp.id }, - addressKey: property.addressKey, - unitName: unitName, - unitType: unitType, - callData: { - callId: faker.datatype.uuid(), - b2cAppCallData: { B2CAppContext: '' }, - }, - }) - - for (const allowedUnitType of ALLOWED_UNIT_TYPES) { - const [result] = await mutate(allowedUnitType) - expect(result.verifiedContactsCount).toBe(1) - expect(result.createdMessagesCount).toBe(0) - expect(result.erroredMessagesCount).toBe(0) - } - - const forbiddenUnitTypes = UNIT_TYPES.filter(unitType => !ALLOWED_UNIT_TYPES.includes(unitType)) - expect(forbiddenUnitTypes).toHaveLength(3) - - for (const forbiddenUnitType of forbiddenUnitTypes) { - await expectToThrowGraphQLRequestError( - async () => await mutate(forbiddenUnitType), - `Variable "$data" got invalid value "${forbiddenUnitType}" at "data.unitType"; Value "${forbiddenUnitType}" does not exist in "AllowedVoIPMessageUnitType" enum.` - ) - } - }) describe('Cache', () => { test('Saves call status in cache', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - const unitName = faker.random.alphaNumeric(3) const unitType = FLAT_UNIT_TYPE @@ -347,7 +357,7 @@ describe('SendVoIPStartMessageService', () => { const callId = faker.datatype.uuid() - const [result] = await sendVoIPStartMessageByTestClient(admin, { + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { app: { id: b2cApp.id }, addressKey: property.addressKey, unitName: unitName, @@ -364,10 +374,6 @@ describe('SendVoIPStartMessageService', () => { }) test('Saves User.id to Message.id binding', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - const unitName = faker.random.alphaNumeric(3) const unitType = FLAT_UNIT_TYPE @@ -396,7 +402,7 @@ describe('SendVoIPStartMessageService', () => { const callId = faker.datatype.uuid() - const [result] = await sendVoIPStartMessageByTestClient(admin, { + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { app: { id: b2cApp.id }, addressKey: property.addressKey, unitName: unitName, @@ -420,9 +426,6 @@ describe('SendVoIPStartMessageService', () => { }) test('Does not save status if have no users to send push', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) const unitName = faker.random.alphaNumeric(3) await createTestContact(admin, organization, property, { unitName: unitName, @@ -430,7 +433,7 @@ describe('SendVoIPStartMessageService', () => { isVerified: true, }) const callId = faker.datatype.uuid() - const [result] = await sendVoIPStartMessageByTestClient(admin, { + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { app: { id: b2cApp.id }, addressKey: property.addressKey, unitName: unitName, @@ -448,5 +451,326 @@ describe('SendVoIPStartMessageService', () => { expect(cache).toBe(null) }) }) + + describe('voipType priority', () => { + let customVoIPTypeField + + beforeAll(async () => { + [customVoIPTypeField] = await createTestCustomField(admin, { + name: 'voipType', + modelName: 'Contact', + type: 'String', + validationRules: null, + isVisible: false, + priority: 0, + isUniquePerObject: true, + staffCanRead: false, + sender: { dv: 1, fingerprint: faker.random.alphaNumeric(8) }, + }) + }) + + test('complex case with everything', async () => { + const serviceUser = await makeClientWithServiceUser() + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + const [accessRightSet] = await createTestB2CAppAccessRightSet(admin, b2cApp, { canExecuteSendVoIPStartMessage: true }) + await createTestB2CAppAccessRight(admin, serviceUser.user, b2cApp, { accessRightSet: { connect: { id: accessRightSet.id } } }) + + + const [b2bApp] = await createTestB2BApp(admin) + await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) + const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) + await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) + + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + + const residentsCount = 4 + const prepareDataPromises = [] + const actors = [] + + for (let i = 0; i < residentsCount; i++) { + prepareDataPromises.push((async ({ admin, organization, property, unitName, unitType }) => { + const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) + actors.push(actor) + })({ admin, organization, property, unitName, unitType })) + } + await Promise.all(prepareDataPromises) + + const normalVoIPType = 'sip' + const resident1VoIPType = 'variant2' + const resident2VoIPType = 'variant3' + const resident3VoIPType = B2C_APP_VOIP_TYPE + + const customVoIPTypesWithActors = [ + [resident1VoIPType, actors[0]], + [resident2VoIPType, actors[1]], + [resident3VoIPType, actors[2]], + ] + + for (const [customVoIPType, actor] of customVoIPTypesWithActors) { + await createTestCustomValue(serviceUser, customVoIPTypeField, organization, { + sourceType: 'B2BApp', + data: customVoIPType, + itemId: actor.contact.id, + sourceId: b2bApp.id, + isUniquePerObject: true, + }) + } + + const dataAttrs = { + callId: faker.datatype.uuid(), + b2cAppCallData: { + B2CAppContext: faker.random.alphaNumeric(10), + }, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [ + { dtmfCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + { dtmfCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, + ], + stunServers: [faker.internet.ip(), faker.internet.ip()], + codec: 'vp8', + }, + } + + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: unitType, + callData: dataAttrs, + }) + expect(result.verifiedContactsCount).toBe(residentsCount) + expect(result.createdMessagesCount).toBe(residentsCount) + expect(result.erroredMessagesCount).toBe(0) + + const createdMessages = await Message.getAll(admin, { + user: { id_in: actors.map(actor => actor.user.id) }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: residentsCount }) + + expect(createdMessages).toHaveLength(residentsCount) + expect([...new Set(createdMessages.map(msg => msg.user))]).toHaveLength(residentsCount) + + const createdMessagesByResident = createdMessages.reduce((byResident, msg) => { + byResident[msg.meta.data.residentId] = msg + return byResident + }, {}) + expect(Object.keys(createdMessagesByResident)).toEqual(expect.arrayContaining(actors.map(actor => actor.resident.id))) + + + const defaultMetaDataFields = { + B2CAppId: b2cApp.id, + B2CAppName: b2cApp.name, + callId: dataAttrs.callId, + } + const defaultMetaDataB2CAppCallDataFields = { + ...defaultMetaDataFields, + B2CAppContext: dataAttrs.b2cAppCallData.B2CAppContext, + } + const defaultMetaDataNativeCallDataFields = { + ...defaultMetaDataFields, + voipAddress: dataAttrs.nativeCallData.voipAddress, + voipLogin: dataAttrs.nativeCallData.voipLogin, + voipPassword: dataAttrs.nativeCallData.voipPassword, + voipDtfmCommand: dataAttrs.nativeCallData.voipPanels[0].dtmfCommand, + voipPanels: dataAttrs.nativeCallData.voipPanels, + stun: dataAttrs.nativeCallData.stunServers[0], + stunServers: dataAttrs.nativeCallData.stunServers, + codec: dataAttrs.nativeCallData.codec, + } + + const normalVoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === normalVoIPType) + try { + expect(normalVoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('normalVoIPTypeMessages fail') + throw err + } + expect(normalVoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataNativeCallDataFields, + residentId: actors[3].resident.id, + voipType: String(normalVoIPType), + }), + }), + })) + + const resident1VoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === resident1VoIPType) + try { + expect(resident1VoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('resident1VoIPTypeMessages fail') + throw err + } + expect(resident1VoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataNativeCallDataFields, + residentId: actors[0].resident.id, + voipType: String(resident1VoIPType), + }), + }), + })) + + const resident2VoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === resident2VoIPType) + try { + expect(resident2VoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('resident2VoIPTypeMessages fail') + throw err + } + expect(resident2VoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataNativeCallDataFields, + residentId: actors[1].resident.id, + voipType: String(resident2VoIPType), + }), + }), + })) + + const b2cAppVoIPTypeMessages = createdMessages.filter(msg => !!msg.meta.data.B2CAppContext) + expect(b2cAppVoIPTypeMessages).toHaveLength(1) + try { + expect(b2cAppVoIPTypeMessages).toHaveLength(1) + } catch (err) { + console.error('b2cAppVoIPTypeMessages fail') + throw err + } + expect(b2cAppVoIPTypeMessages[0]).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + ...defaultMetaDataB2CAppCallDataFields, + residentId: actors[2].resident.id, + }), + }), + })) + + }) + + test('uses native call voipType when CustomValue is absent', async () => { + const unitName = faker.random.alphaNumeric(3) + const unitType = FLAT_UNIT_TYPE + const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) + + const callData = { + callId: faker.datatype.uuid(), + b2cAppCallData: { B2CAppContext: faker.random.alphaNumeric(10) }, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [{ dtmfCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], + stunServers: [faker.internet.ip()], + codec: 'vp8', + }, + } + + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName, + unitType, + callData, + }) + + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(1) + expect(result.erroredMessagesCount).toBe(0) + + const [createdMessage] = await Message.getAll(admin, { + user: { id: actor.user.id }, + type: VOIP_INCOMING_CALL_MESSAGE_TYPE, + }, { first: 1 }) + + expect(createdMessage).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + data: expect.objectContaining({ + residentId: actor.resident.id, + voipType: NATIVE_VOIP_TYPE, + voipAddress: callData.nativeCallData.voipAddress, + }), + }), + })) + expect(createdMessage.meta.data.B2CAppContext).toBeUndefined() + }) + + }) + + test('applies values from CustomFields with names equals to native voip push keys starting with "voip"', async () => { + const [b2cApp] = await createTestB2CApp(admin) + const { organization, property } = await makeClientWithResidentAccessAndProperty() + await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) + const unitName = faker.random.alphaNumeric(3) + + const serviceUser = await makeClientWithServiceUser() + const [b2bApp] = await createTestB2BApp(admin) + await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) + const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) + await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) + + const [b2cRightSet] = await createTestB2CAppAccessRightSet(admin, b2cApp, { canExecuteSendVoIPStartMessage: true }) + await createTestB2CAppAccessRight(admin, serviceUser.user, b2cApp, { accessRightSet: { connect: { id: b2cRightSet.id } } }) + + + const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType: FLAT_UNIT_TYPE }) + + + const customValue = faker.random.alphaNumeric(8) + const customFieldNames = ['voipAddress', 'voipPassword', 'voipDtfmCommand', 'voipPanels'] + for (const fieldName of customFieldNames) { + const [customField] = await createTestCustomField(admin, { + name: fieldName, + modelName: 'Contact', + type: 'String', + validationRules: null, + isVisible: false, + priority: 0, + isUniquePerObject: true, + staffCanRead: false, + sender: { dv: 1, fingerprint: faker.random.alphaNumeric(8) }, + }) + await createTestCustomValue(serviceUser, customField, organization, { + sourceType: 'B2BApp', + data: customValue, + itemId: actor.contact.id, + sourceId: b2bApp.id, + isUniquePerObject: true, + }) + } + const callId = faker.datatype.uuid() + const [result] = await sendVoIPStartMessageByTestClient(serviceUser, { + app: { id: b2cApp.id }, + addressKey: property.addressKey, + unitName: unitName, + unitType: FLAT_UNIT_TYPE, + callData: { + callId: callId, + nativeCallData: { + voipAddress: faker.internet.ip(), + voipLogin: faker.internet.userName(), + voipPassword: faker.internet.password(), + voipPanels: [{ dtmfCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], + stunServers: [faker.internet.ip()], + codec: 'vp8', + }, + }, + }) + expect(result.verifiedContactsCount).toBe(1) + expect(result.createdMessagesCount).toBe(1) + expect(result.erroredMessagesCount).toBe(0) + + const [createdMessage] = await Message.getAll(admin, { type: VOIP_INCOMING_CALL_MESSAGE_TYPE, user: { id: actor.user.id } }) + + expect(createdMessage.meta.data).toEqual(expect.objectContaining( + Object.fromEntries(customFieldNames.map(fieldName => ([fieldName, customValue]))) + )) + }) }) }) \ No newline at end of file diff --git a/apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js b/apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js deleted file mode 100644 index c6ec162692c..00000000000 --- a/apps/condo/domains/miniapp/schema/sendVoIPStartMessageService.spec.js +++ /dev/null @@ -1,513 +0,0 @@ -// eslint-disable-next-line import/order -const { generateUUIDv4 } = require('@open-condo/miniapp-utils/helpers/uuid') -global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID = generateUUIDv4() -jest.doMock('@open-condo/config', () => { - const actual = jest.requireActual('@open-condo/config') - return new Proxy(actual, { - set () {}, - get (_t, p) { - if (p === 'VOIP_TYPE_CUSTOM_FIELD_ID') { - return global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID - } - return actual[p] - }, - }) -}) - -// eslint-disable-next-line import/order -const index = require('@app/condo/index') -// eslint-disable-next-line import/order -const { faker } = require('@faker-js/faker') - -// eslint-disable-next-line import/order -const { setFakeClientMode } = require('@open-condo/keystone/test.utils') -const { - makeLoggedInAdminClient, -// eslint-disable-next-line import/order -} = require('@open-condo/keystone/test.utils') - -const { createTestContact } = require('@condo/domains/contact/utils/testSchema') -const { MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, DEFAULT_VOIP_TYPE } = require('@condo/domains/miniapp/constants') -const { - createTestB2CApp, - sendVoIPStartMessageByTestClient, - createTestB2CAppProperty, - createTestCustomValue, - createTestB2BApp, - createTestB2BAppAccessRight, - createTestB2BAppContext, - createTestB2BAppAccessRightSet, - createTestAppMessageSetting, -} = require('@condo/domains/miniapp/utils/testSchema') -const { - VOIP_INCOMING_CALL_MESSAGE_TYPE, -} = require('@condo/domains/notification/constants/constants') -const { Message } = require('@condo/domains/notification/utils/testSchema') -const { FLAT_UNIT_TYPE } = require('@condo/domains/property/constants/common') -const { makeClientWithResidentAccessAndProperty } = require('@condo/domains/property/utils/testSchema') -const { createTestResident } = require('@condo/domains/resident/utils/testSchema') -const { - makeClientWithResidentUser, - createTestPhone, - makeClientWithServiceUser, -} = require('@condo/domains/user/utils/testSchema') - -async function prepareSingleActor ({ admin, organization, property, unitName, unitType }) { - const phone = createTestPhone() - const userClient = await makeClientWithResidentUser({}, { phone }) - const [contact] = await createTestContact(admin, organization, property, { - unitName: unitName, - unitType: unitType, - isVerified: true, - phone: phone, - }) - const [resident] = await createTestResident(admin, userClient.user, property, { - unitName: unitName, - unitType: unitType, - }) - - return { - user: userClient.user, - contact, - resident, - } -} - -describe('sendVoIPStartMessageService spec', () => { - setFakeClientMode(index) - - let admin - - afterAll(async () => { - jest.unmock('@open-condo/config') - delete global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID - }) - - beforeAll(async () => { - await index.keystone.lists.CustomField.adapter.create({ - id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID, - dv: 1, - v: 1, - name: faker.random.alphaNumeric(8), - modelName: 'Contact', - type: 'String', - validationRules: null, - isVisible: false, - priority: 0, - isUniquePerObject: true, - staffCanRead: false, - sender: { dv: 1, fingerprint: faker.random.alphaNumeric(8) }, - }) - - admin = await makeLoggedInAdminClient() - }) - - describe('Logic', () => { - - describe('voipType priority', () => { - - test('complex case with everything', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - - const serviceUser = await makeClientWithServiceUser() - const [b2bApp] = await createTestB2BApp(admin) - await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) - const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) - await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) - - const unitName = faker.random.alphaNumeric(3) - const unitType = FLAT_UNIT_TYPE - - const residentsCount = 4 - const prepareDataPromises = [] - const actors = [] - - for (let i = 0; i < residentsCount; i++) { - prepareDataPromises.push((async ({ admin, organization, property, unitName, unitType }) => { - const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) - actors.push(actor) - })({ admin, organization, property, unitName, unitType })) - } - await Promise.all(prepareDataPromises) - - const normalVoIPType = 1 - const resident1VoIPType = 2 - const resident2VoIPType = 3 - const resident3VoIPType = 'b2cApp' - - const customVoIPTypesWithActors = [ - [resident1VoIPType, actors[0]], - [resident2VoIPType, actors[1]], - [resident3VoIPType, actors[2]], - ] - - for (const [customVoIPType, actor] of customVoIPTypesWithActors) { - await createTestCustomValue(serviceUser, { id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID }, organization, { - sourceType: 'B2BApp', - data: String(customVoIPType), - itemId: actor.contact.id, - sourceId: b2bApp.id, - isUniquePerObject: true, - }) - } - - const dataAttrs = { - callId: faker.datatype.uuid(), - b2cAppCallData: { - B2CAppContext: faker.random.alphaNumeric(10), - }, - nativeCallData: { - voipAddress: faker.internet.ip(), - voipLogin: faker.internet.userName(), - voipPassword: faker.internet.password(), - voipPanels: [ - { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, - { dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }, - ], - stunServers: [faker.internet.ip(), faker.internet.ip()], - voipType: normalVoIPType, - codec: 'vp8', - }, - } - - const [result] = await sendVoIPStartMessageByTestClient(admin, { - app: { id: b2cApp.id }, - addressKey: property.addressKey, - unitName: unitName, - unitType: unitType, - callData: dataAttrs, - }) - expect(result.verifiedContactsCount).toBe(residentsCount) - expect(result.createdMessagesCount).toBe(residentsCount) - expect(result.erroredMessagesCount).toBe(0) - - const createdMessages = await Message.getAll(admin, { - user: { id_in: actors.map(actor => actor.user.id) }, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - }, { first: residentsCount }) - - expect(createdMessages).toHaveLength(residentsCount) - expect([...new Set(createdMessages.map(msg => msg.user))]).toHaveLength(residentsCount) - - const createdMessagesByResident = createdMessages.reduce((byResident, msg) => { - byResident[msg.meta.data.residentId] = msg - return byResident - }, {}) - expect(Object.keys(createdMessagesByResident)).toEqual(expect.arrayContaining(actors.map(actor => actor.resident.id))) - - - const defaultMetaDataFields = { - B2CAppId: b2cApp.id, - B2CAppName: b2cApp.name, - callId: dataAttrs.callId, - } - const defaultMetaDataB2CAppCallDataFields = { - ...defaultMetaDataFields, - B2CAppContext: dataAttrs.b2cAppCallData.B2CAppContext, - } - const defaultMetaDataNativeCallDataFields = { - ...defaultMetaDataFields, - voipAddress: dataAttrs.nativeCallData.voipAddress, - voipLogin: dataAttrs.nativeCallData.voipLogin, - voipPassword: dataAttrs.nativeCallData.voipPassword, - voipDtfmCommand: dataAttrs.nativeCallData.voipPanels[0].dtfmCommand, - voipPanels: dataAttrs.nativeCallData.voipPanels, - stun: dataAttrs.nativeCallData.stunServers[0], - stunServers: dataAttrs.nativeCallData.stunServers, - codec: dataAttrs.nativeCallData.codec, - } - - const normalVoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === String(normalVoIPType)) - try { - expect(normalVoIPTypeMessages).toHaveLength(1) - } catch (err) { - console.error('normalVoIPTypeMessages fail') - throw err - } - expect(normalVoIPTypeMessages[0]).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - ...defaultMetaDataNativeCallDataFields, - residentId: actors[3].resident.id, - voipType: String(normalVoIPType), - }), - }), - })) - - const resident1VoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === String(resident1VoIPType)) - try { - expect(resident1VoIPTypeMessages).toHaveLength(1) - } catch (err) { - console.error('resident1VoIPTypeMessages fail') - throw err - } - expect(resident1VoIPTypeMessages[0]).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - ...defaultMetaDataNativeCallDataFields, - residentId: actors[0].resident.id, - voipType: String(resident1VoIPType), - }), - }), - })) - - const resident2VoIPTypeMessages = createdMessages.filter(msg => msg.meta.data.voipType === String(resident2VoIPType)) - try { - expect(resident2VoIPTypeMessages).toHaveLength(1) - } catch (err) { - console.error('resident2VoIPTypeMessages fail') - throw err - } - expect(resident2VoIPTypeMessages[0]).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - ...defaultMetaDataNativeCallDataFields, - residentId: actors[1].resident.id, - voipType: String(resident2VoIPType), - }), - }), - })) - - const b2cAppVoIPTypeMessages = createdMessages.filter(msg => !!msg.meta.data.B2CAppContext) - expect(b2cAppVoIPTypeMessages).toHaveLength(1) - try { - expect(b2cAppVoIPTypeMessages).toHaveLength(1) - } catch (err) { - console.error('b2cAppVoIPTypeMessages fail') - throw err - } - expect(b2cAppVoIPTypeMessages[0]).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - ...defaultMetaDataB2CAppCallDataFields, - residentId: actors[2].resident.id, - }), - }), - })) - - }) - - test('forces b2cApp call when CustomValue.data is not a digit', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - - const serviceUser = await makeClientWithServiceUser() - const [b2bApp] = await createTestB2BApp(admin) - await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) - const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) - await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) - - const unitName = faker.random.alphaNumeric(3) - const unitType = FLAT_UNIT_TYPE - const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) - - await createTestCustomValue(serviceUser, { id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID }, organization, { - sourceType: 'B2BApp', - data: faker.random.alphaNumeric(8), - itemId: actor.contact.id, - sourceId: b2bApp.id, - isUniquePerObject: true, - }) - - await createTestAppMessageSetting(admin, { - b2cApp, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - numberOfNotificationInWindow: 10, - }) - - const callData = { - callId: faker.datatype.uuid(), - b2cAppCallData: { B2CAppContext: faker.random.alphaNumeric(10) }, - nativeCallData: { - voipAddress: faker.internet.ip(), - voipLogin: faker.internet.userName(), - voipPassword: faker.internet.password(), - voipPanels: [{ dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], - stunServers: [faker.internet.ip()], - voipType: 1, - codec: 'vp8', - }, - } - - const [result] = await sendVoIPStartMessageByTestClient(admin, { - app: { id: b2cApp.id }, - addressKey: property.addressKey, - unitName, - unitType, - callData, - }) - - expect(result.verifiedContactsCount).toBe(1) - expect(result.createdMessagesCount).toBe(1) - expect(result.erroredMessagesCount).toBe(0) - - const [createdMessage] = await Message.getAll(admin, { - user: { id: actor.user.id }, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - }, { first: 1 }) - - expect(createdMessage).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - B2CAppContext: callData.b2cAppCallData.B2CAppContext, - residentId: actor.resident.id, - }), - }), - })) - expect(createdMessage.meta.data.voipAddress).toBeUndefined() - expect(createdMessage.meta.data.voipType).toBeUndefined() - - // But if there is no data for B2C call, then just send native one - delete callData.b2cAppCallData - const [anotherResult] = await sendVoIPStartMessageByTestClient(admin, { - app: { id: b2cApp.id }, - addressKey: property.addressKey, - unitName, - unitType, - callData, - }) - expect(anotherResult.verifiedContactsCount).toBe(1) - expect(anotherResult.createdMessagesCount).toBe(1) - expect(anotherResult.erroredMessagesCount).toBe(0) - - const [anotherCreatedMessage] = await Message.getAll(admin, { - user: { id: actor.user.id }, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - }, { first: 1, sortBy: ['createdAt_DESC'] }) - - expect(anotherCreatedMessage).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - voipType: String(callData.nativeCallData.voipType), - residentId: actor.resident.id, - }), - }), - })) - expect(anotherCreatedMessage.meta.data.B2CAppContext).toBeUndefined() - }) - - test(`maps CustomValue.data="${DEFAULT_VOIP_TYPE}" to legacy voipType "${MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY}"`, async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - - const serviceUser = await makeClientWithServiceUser() - const [b2bApp] = await createTestB2BApp(admin) - await createTestB2BAppContext(admin, b2bApp, organization, { status: 'Finished' }) - const [b2bAppAccessRightSet] = await createTestB2BAppAccessRightSet(admin, b2bApp, { canReadOrganizations: true, canReadCustomValues: true, canManageCustomValues: true }) - await createTestB2BAppAccessRight(admin, serviceUser.user, b2bApp, b2bAppAccessRightSet) - - const unitName = faker.random.alphaNumeric(3) - const unitType = FLAT_UNIT_TYPE - const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) - - await createTestCustomValue(serviceUser, { id: global._TEST_VOIP_TYPE_CUSTOM_FIELD_ID }, organization, { - sourceType: 'B2BApp', - data: String(DEFAULT_VOIP_TYPE), - itemId: actor.contact.id, - sourceId: b2bApp.id, - isUniquePerObject: true, - }) - - const callData = { - callId: faker.datatype.uuid(), - b2cAppCallData: { B2CAppContext: faker.random.alphaNumeric(10) }, - nativeCallData: { - voipAddress: faker.internet.ip(), - voipLogin: faker.internet.userName(), - voipPassword: faker.internet.password(), - voipPanels: [{ dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], - stunServers: [faker.internet.ip()], - voipType: 1, - codec: 'vp8', - }, - } - - const [result] = await sendVoIPStartMessageByTestClient(admin, { - app: { id: b2cApp.id }, - addressKey: property.addressKey, - unitName, - unitType, - callData, - }) - - expect(result.verifiedContactsCount).toBe(1) - expect(result.createdMessagesCount).toBe(1) - expect(result.erroredMessagesCount).toBe(0) - - const [createdMessage] = await Message.getAll(admin, { - user: { id: actor.user.id }, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - }, { first: 1 }) - - expect(createdMessage).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - residentId: actor.resident.id, - voipType: MAGIC_VOIP_TYPE_CONSTANT_FOR_OLD_VERSIONS_COMPATIBILITY, - voipAddress: callData.nativeCallData.voipAddress, - }), - }), - })) - }) - - test('uses native call voipType when CustomValue is absent', async () => { - const [b2cApp] = await createTestB2CApp(admin) - const { organization, property } = await makeClientWithResidentAccessAndProperty() - await createTestB2CAppProperty(admin, b2cApp, { address: property.address, addressMeta: property.addressMeta }) - - const unitName = faker.random.alphaNumeric(3) - const unitType = FLAT_UNIT_TYPE - const actor = await prepareSingleActor({ admin, organization, property, unitName, unitType }) - - const nativeVoIPType = 3 - const callData = { - callId: faker.datatype.uuid(), - b2cAppCallData: { B2CAppContext: faker.random.alphaNumeric(10) }, - nativeCallData: { - voipAddress: faker.internet.ip(), - voipLogin: faker.internet.userName(), - voipPassword: faker.internet.password(), - voipPanels: [{ dtfmCommand: faker.random.alphaNumeric(2), name: faker.random.alphaNumeric(3) }], - stunServers: [faker.internet.ip()], - voipType: nativeVoIPType, - codec: 'vp8', - }, - } - - const [result] = await sendVoIPStartMessageByTestClient(admin, { - app: { id: b2cApp.id }, - addressKey: property.addressKey, - unitName, - unitType, - callData, - }) - - expect(result.verifiedContactsCount).toBe(1) - expect(result.createdMessagesCount).toBe(1) - expect(result.erroredMessagesCount).toBe(0) - - const [createdMessage] = await Message.getAll(admin, { - user: { id: actor.user.id }, - type: VOIP_INCOMING_CALL_MESSAGE_TYPE, - }, { first: 1 }) - - expect(createdMessage).toEqual(expect.objectContaining({ - meta: expect.objectContaining({ - data: expect.objectContaining({ - residentId: actor.resident.id, - voipType: String(nativeVoIPType), - voipAddress: callData.nativeCallData.voipAddress, - }), - }), - })) - expect(createdMessage.meta.data.B2CAppContext).toBeUndefined() - }) - - }) - }) - -}) \ No newline at end of file diff --git a/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/config.js b/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/config.js index 04f48945f01..74600c99d7d 100644 --- a/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/config.js +++ b/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/config.js @@ -25,7 +25,7 @@ const B2C_APP_SERVICE_USER_ACCESS_AVAILABLE_SCHEMAS = { }, // Something that has B2CApp info and addressKey services: { - // sendVoIPStartPushMessage: {}, // NOTE(YEgorLu): to be made in DOMA-12905 + sendVoIPStartMessage: {}, // sendVoIPCancelPushMessage: {}, // NOTE(YEgorLu): to be made in DOMA-12906 }, } diff --git a/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/server.utils.js b/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/server.utils.js index f8947311363..b24d37394d4 100644 --- a/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/server.utils.js +++ b/apps/condo/domains/miniapp/utils/b2cAppServiceUserAccess/server.utils.js @@ -86,7 +86,7 @@ async function canReadByServiceUser (args, schemaConfig) { const permissionKey = `canRead${pluralize.plural(listKey)}` - return getFilterByFieldPathValue(pathToB2CApp, getB2CAppFilter({ user, requirePermission: !!schemaConfig.rightSetRequired, permissionKey })) + return getFilterByFieldPathValue(pathToB2CApp, getB2CAppFilter({ user, requirePermission: schemaConfig.rightSetRequired !== false, permissionKey })) } async function canManageByServiceUser ({ authentication: { item: user }, listKey, originalInput, itemId, itemIds, operation, context }, schemaConfig, parentSchemaName) { @@ -174,7 +174,7 @@ async function canManageByServiceUser ({ authentication: { item: user }, listKey const uniqueB2CAppIds = [...new Set(b2cAppIds)] const b2cApps = await find('B2CApp', { - ...getB2CAppFilter({ user, requirePermission: !!schemaConfig.rightSetRequired, permissionKey }), + ...getB2CAppFilter({ user, requirePermission: schemaConfig.rightSetRequired !== false, permissionKey }), id_in: uniqueB2CAppIds, deletedAt: null, }) @@ -207,7 +207,7 @@ async function canExecuteByServiceUser (params, serviceConfig) { deletedAt: null, addressKey, app: { - ...getB2CAppFilter({ user, requirePermission: !!serviceConfig.rightSetRequired, permissionKey }), + ...getB2CAppFilter({ user, requirePermission: serviceConfig.rightSetRequired !== false, permissionKey }), id: b2cAppId, deletedAt: null, }, diff --git a/apps/condo/domains/property/utils/serverSchema/helpers.js b/apps/condo/domains/property/utils/serverSchema/helpers.js index 0a34d281a2f..9c75e614694 100644 --- a/apps/condo/domains/property/utils/serverSchema/helpers.js +++ b/apps/condo/domains/property/utils/serverSchema/helpers.js @@ -2,6 +2,10 @@ const { get, omitBy, isNull, isArray } = require('lodash') const FLAT_WITHOUT_FLAT_TYPE_MESSAGE = 'Flat is specified, but flat type is not!' +const { itemsQuery } = require('@open-condo/keystone/schema') + +const { MANAGING_COMPANY_TYPE } = require('@condo/domains/organization/constants/common') + /** * Sometimes address can contain flat with prefix, for example, in case of scanning receipt with QR-code. * Input data is out of control ;) @@ -102,10 +106,24 @@ const getUnitsFromSections = (sections = []) => { ).flat(2) } +async function getOldestNonDeletedProperty ({ addressKey }) { + const [oldestProperty] = await itemsQuery('Property', { + where: { + addressKey: addressKey, + deletedAt: null, + organization: { type: MANAGING_COMPANY_TYPE }, + }, + first: 1, + sortBy: ['isApproved_DESC', 'createdAt_ASC'], // sorting order is essential here + }) + return oldestProperty +} + module.exports = { FLAT_WITHOUT_FLAT_TYPE_MESSAGE, getAddressUpToBuildingFrom, normalizePropertyMap, getAddressDetails, getUnitsFromSections, + getOldestNonDeletedProperty, } diff --git a/apps/condo/migrations/20260420183110-0525_b2cappaccessrightset_canexecutesendvoipstartmessage_and_more.js b/apps/condo/migrations/20260420183110-0525_b2cappaccessrightset_canexecutesendvoipstartmessage_and_more.js new file mode 100644 index 00000000000..35aff361953 --- /dev/null +++ b/apps/condo/migrations/20260420183110-0525_b2cappaccessrightset_canexecutesendvoipstartmessage_and_more.js @@ -0,0 +1,48 @@ +// auto generated by kmigrator +// KMIGRATOR:0525_b2cappaccessrightset_canexecutesendvoipstartmessage_and_more:IyBHZW5lcmF0ZWQgYnkgRGphbmdvIDUuMS41IG9uIDIwMjYtMDQtMjAgMTM6MzEKCmZyb20gZGphbmdvLmRiIGltcG9ydCBtaWdyYXRpb25zLCBtb2RlbHMKCgpjbGFzcyBNaWdyYXRpb24obWlncmF0aW9ucy5NaWdyYXRpb24pOgoKICAgIGRlcGVuZGVuY2llcyA9IFsKICAgICAgICAoJ19kamFuZ29fc2NoZW1hJywgJzA1MjRfYjJjYXBwYWNjZXNzcmlnaHRzZXRoaXN0b3J5cmVjb3JkX2FuZF9tb3JlJyksCiAgICBdCgogICAgb3BlcmF0aW9ucyA9IFsKICAgICAgICBtaWdyYXRpb25zLkFkZEZpZWxkKAogICAgICAgICAgICBtb2RlbF9uYW1lPSdiMmNhcHBhY2Nlc3NyaWdodHNldCcsCiAgICAgICAgICAgIG5hbWU9J2NhbkV4ZWN1dGVTZW5kVm9JUFN0YXJ0TWVzc2FnZScsCiAgICAgICAgICAgIGZpZWxkPW1vZGVscy5Cb29sZWFuRmllbGQoZGVmYXVsdD1GYWxzZSksCiAgICAgICAgKSwKICAgICAgICBtaWdyYXRpb25zLkFkZEZpZWxkKAogICAgICAgICAgICBtb2RlbF9uYW1lPSdiMmNhcHBhY2Nlc3NyaWdodHNldGhpc3RvcnlyZWNvcmQnLAogICAgICAgICAgICBuYW1lPSdjYW5FeGVjdXRlU2VuZFZvSVBTdGFydE1lc3NhZ2UnLAogICAgICAgICAgICBmaWVsZD1tb2RlbHMuQm9vbGVhbkZpZWxkKGJsYW5rPVRydWUsIG51bGw9VHJ1ZSksCiAgICAgICAgKSwKICAgIF0K +// KMIGRATOR_VIEWS:0525_b2cappaccessrightset_canexecutesendvoipstartmessage_and_more:eyJkdiI6MSwibGlzdHMiOnsiVXNlciI6eyJmaWVsZHMiOlsiYXZhdGFyIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiY3VzdG9tQWNjZXNzIiwiZGVsZXRlZEF0IiwiZHYiLCJlbWFpbCIsImV4dGVybmFsRW1haWwiLCJleHRlcm5hbFBob25lIiwiZXh0ZXJuYWxTeXN0ZW1OYW1lIiwiaGFzTWFya2V0aW5nQ29uc2VudCIsImlkIiwiaXNBZG1pbiIsImlzRW1haWxWZXJpZmllZCIsImlzRXh0ZXJuYWxFbWFpbFZlcmlmaWVkIiwiaXNFeHRlcm5hbFBob25lVmVyaWZpZWQiLCJpc1Bob25lVmVyaWZpZWQiLCJpc1N1cHBvcnQiLCJpc1R3b0ZhY3RvckF1dGhlbnRpY2F0aW9uRW5hYmxlZCIsImxvY2FsZSIsIm1ldGEiLCJuYW1lIiwibmV3SWQiLCJwYXNzd29yZCIsInBob25lIiwicmlnaHRzU2V0Iiwic2VuZGVyIiwic2hvd0dsb2JhbEhpbnRzIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImVtYWlsIiwiZXh0ZXJuYWxFbWFpbCIsImV4dGVybmFsUGhvbmUiLCJtZXRhIiwibmFtZSIsInBhc3N3b3JkIiwicGhvbmUiXX0sIlVzZXJFeHRlcm5hbElkZW50aXR5Ijp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaWRlbnRpdHlJZCIsImlkZW50aXR5VHlwZSIsIm1ldGEiLCJuZXdJZCIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ1c2VyVHlwZSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImlkZW50aXR5SWQiLCJtZXRhIl19LCJVc2VyUmlnaHRzU2V0Ijp7ImZpZWxkcyI6WyJjYW5FeGVjdXRlR2V0QXZhaWxhYmxlU3Vic2NyaXB0aW9uUGxhbnMiLCJjYW5FeGVjdXRlUmVnaXN0ZXJOZXdTZXJ2aWNlVXNlciIsImNhbkV4ZWN1dGVSZWdpc3RlclN1YnNjcmlwdGlvbkNvbnRleHQiLCJjYW5FeGVjdXRlU2VuZE1lc3NhZ2UiLCJjYW5FeGVjdXRlX2FsbEJpbGxpbmdSZWNlaXB0c1N1bSIsImNhbkV4ZWN1dGVfYWxsUGF5bWVudHNTdW0iLCJjYW5FeGVjdXRlX2ludGVybmFsU2VuZEhhc2hlZFJlc2lkZW50UGhvbmVzIiwiY2FuTWFuYWdlQjJCQXBwQWNjZXNzUmlnaHRTZXRzIiwiY2FuTWFuYWdlQjJCQXBwQWNjZXNzUmlnaHRzIiwiY2FuTWFuYWdlQjJCQXBwQ29udGV4dHMiLCJjYW5NYW5hZ2VCMkJBcHBOZXdzU2hhcmluZ0NvbmZpZ3MiLCJjYW5NYW5hZ2VCMkJBcHBQZXJtaXNzaW9ucyIsImNhbk1hbmFnZUIyQkFwcFByb21vQmxvY2tzIiwiY2FuTWFuYWdlQjJCQXBwcyIsImNhbk1hbmFnZUIyQ0FwcEFjY2Vzc1JpZ2h0cyIsImNhbk1hbmFnZUIyQ0FwcEJ1aWxkcyIsImNhbk1hbmFnZUIyQ0FwcFByb3BlcnRpZXMiLCJjYW5NYW5hZ2VCMkNBcHBzIiwiY2FuTWFuYWdlQmlsbGluZ0ludGVncmF0aW9uT3JnYW5pemF0aW9uQ29udGV4dERlbGV0ZWRBdEZpZWxkIiwiY2FuTWFuYWdlTWVzc2FnZUJhdGNoZXMiLCJjYW5NYW5hZ2VPaWRjQ2xpZW50cyIsImNhbk1hbmFnZU9yZ2FuaXphdGlvbklzQXBwcm92ZWRGaWVsZCIsImNhbk1hbmFnZU9yZ2FuaXphdGlvbnMiLCJjYW5NYW5hZ2VQcm9wZXJ0aWVzIiwiY2FuTWFuYWdlUmVzZXRVc2VyTGltaXRBY3Rpb25zIiwiY2FuTWFuYWdlVGlja2V0QXV0b0Fzc2lnbm1lbnRzIiwiY2FuTWFuYWdlVGlja2V0U2VudFRvQXV0aG9yaXRpZXNBdEZpZWxkIiwiY2FuTWFuYWdlVGlja2V0cyIsImNhbk1hbmFnZVVzZXJIYXNNYXJrZXRpbmdDb25zZW50RmllbGQiLCJjYW5NYW5hZ2VVc2VyUmlnaHRzU2V0RmllbGQiLCJjYW5NYW5hZ2VVc2VyUmlnaHRzU2V0cyIsImNhblJlYWRCMkJBcHBBY2Nlc3NSaWdodFNldHMiLCJjYW5SZWFkQjJCQXBwQWNjZXNzUmlnaHRzIiwiY2FuUmVhZEIyQkFwcENvbnRleHRzIiwiY2FuUmVhZEIyQkFwcE5ld3NTaGFyaW5nQ29uZmlncyIsImNhblJlYWRCMkJBcHBQZXJtaXNzaW9ucyIsImNhblJlYWRCMkJBcHBQcm9tb0Jsb2NrcyIsImNhblJlYWRCMkJBcHBzIiwiY2FuUmVhZEIyQ0FwcEFjY2Vzc1JpZ2h0cyIsImNhblJlYWRCMkNBcHBCdWlsZHMiLCJjYW5SZWFkQjJDQXBwUHJvcGVydGllcyIsImNhblJlYWRCMkNBcHBzIiwiY2FuUmVhZEJpbGxpbmdJbnRlZ3JhdGlvbk9yZ2FuaXphdGlvbkNvbnRleHRzIiwiY2FuUmVhZEJpbGxpbmdSZWNlaXB0cyIsImNhblJlYWRNZXNzYWdlQmF0Y2hlcyIsImNhblJlYWRNZXNzYWdlcyIsImNhblJlYWRPaWRjQ2xpZW50cyIsImNhblJlYWRPcmdhbml6YXRpb25zIiwiY2FuUmVhZFBheW1lbnRzIiwiY2FuUmVhZFByb3BlcnRpZXMiLCJjYW5SZWFkUmVzZXRVc2VyTGltaXRBY3Rpb25zIiwiY2FuUmVhZFJlc2lkZW50cyIsImNhblJlYWRUaWNrZXRBdXRvQXNzaWdubWVudHMiLCJjYW5SZWFkVGlja2V0cyIsImNhblJlYWRVc2VyRW1haWxGaWVsZCIsImNhblJlYWRVc2VyUGhvbmVGaWVsZCIsImNhblJlYWRVc2VyUmlnaHRzU2V0cyIsImNhblJlYWRVc2VycyIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuYW1lIiwibmV3SWQiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJjYW5FeGVjdXRlX2ludGVybmFsU2VuZEhhc2hlZFJlc2lkZW50UGhvbmVzIiwiY2FuUmVhZFVzZXJFbWFpbEZpZWxkIiwiY2FuUmVhZFVzZXJQaG9uZUZpZWxkIl19LCJPcmdhbml6YXRpb24iOnsiZmllbGRzIjpbImF2YXRhciIsImNvdW50cnkiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkZXNjcmlwdGlvbiIsImR2IiwiZmVhdHVyZXMiLCJpZCIsImltcG9ydElkIiwiaW1wb3J0UmVtb3RlU3lzdGVtIiwiaXNBcHByb3ZlZCIsIm1ldGEiLCJuYW1lIiwibmV3SWQiLCJwaG9uZSIsInBob25lTnVtYmVyUHJlZml4Iiwic2VuZGVyIiwidGluIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbInBob25lIiwicGhvbmVOdW1iZXJQcmVmaXgiXX0sIk9yZ2FuaXphdGlvbkVtcGxveWVlIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImVtYWlsIiwiaGFzQWxsU3BlY2lhbGl6YXRpb25zIiwiaWQiLCJpbnZpdGVDb2RlIiwiaXNBY2NlcHRlZCIsImlzQmxvY2tlZCIsImlzUmVqZWN0ZWQiLCJuYW1lIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJwaG9uZSIsInBvc2l0aW9uIiwicm9sZSIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJlbWFpbCIsImludml0ZUNvZGUiLCJuYW1lIiwicGhvbmUiXX0sIk9yZ2FuaXphdGlvbkVtcGxveWVlUm9sZSI6eyJmaWVsZHMiOlsiY2FuQmVBc3NpZ25lZEFzRXhlY3V0b3IiLCJjYW5CZUFzc2lnbmVkQXNSZXNwb25zaWJsZSIsImNhbkRvd25sb2FkQ2FsbFJlY29yZHMiLCJjYW5JbXBvcnRCaWxsaW5nUmVjZWlwdHMiLCJjYW5JbnZpdGVOZXdPcmdhbml6YXRpb25FbXBsb3llZXMiLCJjYW5NYW5hZ2VCMkJBcHBzIiwiY2FuTWFuYWdlQmFua0FjY291bnRSZXBvcnRUYXNrcyIsImNhbk1hbmFnZUJhbmtBY2NvdW50UmVwb3J0cyIsImNhbk1hbmFnZUJhbmtBY2NvdW50cyIsImNhbk1hbmFnZUJhbmtDb250cmFjdG9yQWNjb3VudHMiLCJjYW5NYW5hZ2VCYW5rSW50ZWdyYXRpb25BY2NvdW50Q29udGV4dHMiLCJjYW5NYW5hZ2VCYW5rSW50ZWdyYXRpb25Pcmdhbml6YXRpb25Db250ZXh0cyIsImNhbk1hbmFnZUJhbmtUcmFuc2FjdGlvbnMiLCJjYW5NYW5hZ2VDYWxsUmVjb3JkcyIsImNhbk1hbmFnZUNvbnRhY3RSb2xlcyIsImNhbk1hbmFnZUNvbnRhY3RzIiwiY2FuTWFuYWdlRG9jdW1lbnRzIiwiY2FuTWFuYWdlRW1wbG95ZWVzIiwiY2FuTWFuYWdlSW5jaWRlbnRzIiwiY2FuTWFuYWdlSW50ZWdyYXRpb25zIiwiY2FuTWFuYWdlSW52b2ljZXMiLCJjYW5NYW5hZ2VNYXJrZXRJdGVtUHJpY2VzIiwiY2FuTWFuYWdlTWFya2V0SXRlbXMiLCJjYW5NYW5hZ2VNYXJrZXRQcmljZVNjb3BlcyIsImNhbk1hbmFnZU1hcmtldFNldHRpbmciLCJjYW5NYW5hZ2VNYXJrZXRwbGFjZSIsImNhbk1hbmFnZU1ldGVyUmVhZGluZ3MiLCJjYW5NYW5hZ2VNZXRlcnMiLCJjYW5NYW5hZ2VNb2JpbGVGZWF0dXJlQ29uZmlncyIsImNhbk1hbmFnZU5ld3NJdGVtVGVtcGxhdGVzIiwiY2FuTWFuYWdlTmV3c0l0ZW1zIiwiY2FuTWFuYWdlT3JnYW5pemF0aW9uIiwiY2FuTWFuYWdlT3JnYW5pemF0aW9uRW1wbG95ZWVSZXF1ZXN0cyIsImNhbk1hbmFnZVByb3BlcnRpZXMiLCJjYW5NYW5hZ2VQcm9wZXJ0eVNjb3BlcyIsImNhbk1hbmFnZVJvbGVzIiwiY2FuTWFuYWdlU3Vic2NyaXB0aW9ucyIsImNhbk1hbmFnZVRpY2tldEF1dG9Bc3NpZ25tZW50cyIsImNhbk1hbmFnZVRpY2tldENvbW1lbnRzIiwiY2FuTWFuYWdlVGlja2V0UHJvcGVydHlIaW50cyIsImNhbk1hbmFnZVRpY2tldHMiLCJjYW5NYW5hZ2VUb3VyIiwiY2FuUmVhZEFuYWx5dGljcyIsImNhblJlYWRCaWxsaW5nUmVjZWlwdHMiLCJjYW5SZWFkQ2FsbFJlY29yZHMiLCJjYW5SZWFkQ29udGFjdHMiLCJjYW5SZWFkRG9jdW1lbnRzIiwiY2FuUmVhZEVtcGxveWVlcyIsImNhblJlYWRFeHRlcm5hbFJlcG9ydHMiLCJjYW5SZWFkSW5jaWRlbnRzIiwiY2FuUmVhZEludm9pY2VzIiwiY2FuUmVhZE1hcmtldEl0ZW1QcmljZXMiLCJjYW5SZWFkTWFya2V0SXRlbXMiLCJjYW5SZWFkTWFya2V0UHJpY2VTY29wZXMiLCJjYW5SZWFkTWFya2V0U2V0dGluZyIsImNhblJlYWRNYXJrZXRwbGFjZSIsImNhblJlYWRNZXRlcnMiLCJjYW5SZWFkTmV3c0l0ZW1zIiwiY2FuUmVhZFBheW1lbnRzIiwiY2FuUmVhZFBheW1lbnRzV2l0aEludm9pY2VzIiwiY2FuUmVhZFByb3BlcnRpZXMiLCJjYW5SZWFkU2VydmljZXMiLCJjYW5SZWFkU2V0dGluZ3MiLCJjYW5SZWFkVGlja2V0cyIsImNhblJlYWRUb3VyIiwiY2FuU2hhcmVUaWNrZXRzIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZGVzY3JpcHRpb24iLCJkdiIsImlkIiwiaXNEZWZhdWx0IiwiaXNFZGl0YWJsZSIsIm5hbWUiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInRpY2tldFZpc2liaWxpdHlUeXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiY2FuUmVhZFNldHRpbmdzIl19LCJPcmdhbml6YXRpb25MaW5rIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImZyb20iLCJpZCIsIm5ld0lkIiwic2VuZGVyIiwidG8iLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJPcmdhbml6YXRpb25FbXBsb3llZVNwZWNpYWxpemF0aW9uIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImVtcGxveWVlIiwiaWQiLCJuZXdJZCIsInNlbmRlciIsInNwZWNpYWxpemF0aW9uIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiT3JnYW5pemF0aW9uRW1wbG95ZWVSZXF1ZXN0Ijp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJjcmVhdGVkRW1wbG95ZWUiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaXNBY2NlcHRlZCIsImlzUmVqZWN0ZWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsIm9yZ2FuaXphdGlvbklkIiwib3JnYW5pemF0aW9uTmFtZSIsIm9yZ2FuaXphdGlvblRpbiIsInByb2Nlc3NlZEF0IiwicHJvY2Vzc2VkQnkiLCJyZXRyaWVzIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInVzZXJOYW1lIiwidXNlclBob25lIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsidXNlck5hbWUiLCJ1c2VyUGhvbmUiXX0sIlByb3BlcnR5Ijp7ImZpZWxkcyI6WyJhZGRyZXNzIiwiYWRkcmVzc0tleSIsImFkZHJlc3NNZXRhIiwiYWRkcmVzc1NvdXJjZXMiLCJhcmVhIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImlzQXBwcm92ZWQiLCJtYXAiLCJuYW1lIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ0eXBlIiwidW5pbmhhYml0ZWRVbml0c0NvdW50IiwidW5pdHNDb3VudCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiLCJ5ZWFyT2ZDb25zdHJ1Y3Rpb24iXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkJpbGxpbmdJbnRlZ3JhdGlvbiI6eyJmaWVsZHMiOlsiYXBwVXJsIiwiYjJiQXBwIiwiYmFubmVyQ29sb3IiLCJiYW5uZXJQcm9tb0ltYWdlIiwiYmFubmVyVGV4dENvbG9yIiwiYmlsbGluZ1BhZ2VJY29uIiwiYmlsbGluZ1BhZ2VUaXRsZSIsImNoZWNrQWNjb3VudE51bWJlclVybCIsImNoZWNrQWRkcmVzc1VybCIsImNvbm5lY3RlZE1lc3NhZ2UiLCJjb25uZWN0ZWRVcmwiLCJjb250ZXh0RGVmYXVsdFN0YXR1cyIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1cnJlbmN5Q29kZSIsImRhdGFGb3JtYXQiLCJkZWxldGVkQXQiLCJkZXRhaWxlZERlc2NyaXB0aW9uIiwiZHYiLCJleHRlbmRzQmlsbGluZ1BhZ2UiLCJncm91cCIsImlkIiwiaW5zdHJ1Y3Rpb24iLCJpbnN0cnVjdGlvbkV4dHJhTGluayIsImlzSGlkZGVuIiwiaXNUcnVzdGVkQmFua0FjY291bnRTb3VyY2UiLCJsb2dvIiwibmFtZSIsIm5ld0lkIiwicmVjZWlwdHNMb2FkaW5nVGltZSIsInNlbmRlciIsInNldHVwVXJsIiwic2hvcnREZXNjcmlwdGlvbiIsInNraXBOb0FjY291bnROb3RpZmljYXRpb25zIiwidGFyZ2V0RGVzY3JpcHRpb24iLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1cGxvYWRNZXNzYWdlIiwidXBsb2FkVXJsIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQmlsbGluZ0ludGVncmF0aW9uQWNjZXNzUmlnaHQiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJpbnRlZ3JhdGlvbiIsIm5ld0lkIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkJpbGxpbmdJbnRlZ3JhdGlvbk9yZ2FuaXphdGlvbkNvbnRleHQiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1cnJlbnRQcm9ibGVtIiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImludGVncmF0aW9uIiwibGFzdFJlcG9ydCIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwic2V0dGluZ3MiLCJzdGF0ZSIsInN0YXR1cyIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbInNldHRpbmdzIiwic3RhdGUiXX0sIkJpbGxpbmdJbnRlZ3JhdGlvblByb2JsZW0iOnsiZmllbGRzIjpbImNvbnRleHQiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibWVzc2FnZSIsIm1ldGEiLCJuZXdJZCIsInNlbmRlciIsInRpdGxlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQmlsbGluZ1Byb3BlcnR5Ijp7ImZpZWxkcyI6WyJhZGRyZXNzIiwiYWRkcmVzc0tleSIsImFkZHJlc3NNZXRhIiwiYWRkcmVzc1NvdXJjZXMiLCJjb250ZXh0IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJnbG9iYWxJZCIsImlkIiwiaW1wb3J0SWQiLCJtZXRhIiwibmV3SWQiLCJub3JtYWxpemVkQWRkcmVzcyIsInJhdyIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbIm1ldGEiLCJyYXciXX0sIkJpbGxpbmdBY2NvdW50Ijp7ImZpZWxkcyI6WyJjb250ZXh0IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJmdWxsTmFtZSIsImdsb2JhbElkIiwiaWQiLCJpbXBvcnRJZCIsImlzQ2xvc2VkIiwibWV0YSIsIm5ld0lkIiwibnVtYmVyIiwib3duZXJUeXBlIiwicHJvcGVydHkiLCJyYXciLCJzZW5kZXIiLCJ1bml0TmFtZSIsInVuaXRUeXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZnVsbE5hbWUiLCJtZXRhIiwicmF3Il19LCJCaWxsaW5nUmVjZWlwdCI6eyJmaWVsZHMiOlsiYWNjb3VudCIsImFtb3VudERpc3RyaWJ1dGlvbiIsImJhbGFuY2UiLCJiYWxhbmNlVXBkYXRlZEF0IiwiY2F0ZWdvcnkiLCJjaGFyZ2UiLCJjb250ZXh0IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJmaWxlIiwiZm9ybXVsYSIsImlkIiwiaW1wb3J0SWQiLCJuZXdJZCIsInBhaWQiLCJwYXltZW50U3RhdHVzQ2hhbmdlV2ViaG9va1NlY3JldCIsInBheW1lbnRTdGF0dXNDaGFuZ2VXZWJob29rVXJsIiwicGVuYWx0eSIsInBlcmlvZCIsInByaW50YWJsZU51bWJlciIsInByaXZpbGVnZSIsInByb3BlcnR5IiwicmF3IiwicmVjYWxjdWxhdGlvbiIsInJlY2VpdmVyIiwicmVjaXBpZW50Iiwic2VuZGVyIiwic2VydmljZXMiLCJ0b1BheSIsInRvUGF5RGV0YWlscyIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImFtb3VudERpc3RyaWJ1dGlvbiIsInBheW1lbnRTdGF0dXNDaGFuZ2VXZWJob29rU2VjcmV0IiwicGF5bWVudFN0YXR1c0NoYW5nZVdlYmhvb2tVcmwiLCJyYXciLCJzZXJ2aWNlcyIsInRvUGF5RGV0YWlscyJdfSwiQmlsbGluZ1JlY2lwaWVudCI6eyJmaWVsZHMiOlsiYmFua0FjY291bnQiLCJiYW5rTmFtZSIsImJpYyIsImNsYXNzaWZpY2F0aW9uQ29kZSIsImNvbnRleHQiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaWVjIiwiaW1wb3J0SWQiLCJtZXRhIiwibmFtZSIsIm5ld0lkIiwib2Zmc2V0dGluZ0FjY291bnQiLCJwdXJwb3NlIiwic2VuZGVyIiwidGVycml0b3J5Q29kZSIsInRpbiIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbIm1ldGEiXX0sIkJpbGxpbmdDYXRlZ29yeSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5hbWUiLCJuZXdJZCIsInJlY2VpcHRWYWxpZGl0eU1vbnRocyIsInJlcXVpcmVzRnVsbFBheW1lbnQiLCJzZW5kZXIiLCJzZXJ2aWNlTmFtZXMiLCJza2lwTm90aWZpY2F0aW9ucyIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkJhbmtBY2NvdW50Ijp7ImZpZWxkcyI6WyJhcHByb3ZlZEF0IiwiYXBwcm92ZWRCeSIsImJhbmtOYW1lIiwiY2xhc3NpZmljYXRpb25Db2RlIiwiY291bnRyeSIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1cnJlbmN5Q29kZSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJpbXBvcnRJZCIsImludGVncmF0aW9uQ29udGV4dCIsImlzQXBwcm92ZWQiLCJtZXRhIiwibmFtZSIsIm5ld0lkIiwibnVtYmVyIiwib3JnYW5pemF0aW9uIiwicHJvcGVydHkiLCJyb3V0aW5nTnVtYmVyIiwicm91dGluZ051bWJlck1ldGEiLCJzZW5kZXIiLCJ0ZXJyaXRvcnlDb2RlIiwidGluIiwidGluTWV0YSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbIm1ldGEiLCJyb3V0aW5nTnVtYmVyTWV0YSIsInRpbk1ldGEiXX0sIkJhbmtDYXRlZ29yeSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5hbWUiLCJuZXdJZCIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkJhbmtDb3N0SXRlbSI6eyJmaWVsZHMiOlsiY2F0ZWdvcnkiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaXNPdXRjb21lIiwibmFtZSIsIm5ld0lkIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQmFua0NvbnRyYWN0b3JBY2NvdW50Ijp7ImZpZWxkcyI6WyJiYW5rTmFtZSIsImNvc3RJdGVtIiwiY291bnRyeSIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1cnJlbmN5Q29kZSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJpbXBvcnRJZCIsIm1ldGEiLCJuYW1lIiwibmV3SWQiLCJudW1iZXIiLCJvcmdhbml6YXRpb24iLCJyb3V0aW5nTnVtYmVyIiwic2VuZGVyIiwidGVycml0b3J5Q29kZSIsInRpbiIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbIm1ldGEiXX0sIkJhbmtJbnRlZ3JhdGlvbiI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5hbWUiLCJuZXdJZCIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkJhbmtJbnRlZ3JhdGlvbkFjY2Vzc1JpZ2h0Ijp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaW50ZWdyYXRpb24iLCJuZXdJZCIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJCYW5rSW50ZWdyYXRpb25BY2NvdW50Q29udGV4dCI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJlbmFibGVkIiwiaWQiLCJpbnRlZ3JhdGlvbiIsIm1ldGEiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbIm1ldGEiXX0sIkJhbmtUcmFuc2FjdGlvbiI6eyJmaWVsZHMiOlsiYWNjb3VudCIsImFtb3VudCIsImNvbnRyYWN0b3JBY2NvdW50IiwiY29zdEl0ZW0iLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJjdXJyZW5jeUNvZGUiLCJkYXRlIiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImltcG9ydElkIiwiaW1wb3J0UmVtb3RlU3lzdGVtIiwiaW50ZWdyYXRpb25Db250ZXh0IiwiaXNPdXRjb21lIiwibWV0YSIsIm5ld0lkIiwibnVtYmVyIiwib3JnYW5pemF0aW9uIiwicHVycG9zZSIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbIm1ldGEiXX0sIkJhbmtJbnRlZ3JhdGlvbk9yZ2FuaXphdGlvbkNvbnRleHQiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZW5hYmxlZCIsImlkIiwiaW50ZWdyYXRpb24iLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlRpY2tldCI6eyJmaWVsZHMiOlsiYXNzaWduZWUiLCJjYW5SZWFkQnlSZXNpZGVudCIsImNhdGVnb3J5Q2xhc3NpZmllciIsImNsYXNzaWZpZXIiLCJjbGllbnQiLCJjbGllbnRFbWFpbCIsImNsaWVudE5hbWUiLCJjbGllbnRQaG9uZSIsImNvbXBsZXRlZEF0IiwiY29udGFjdCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1c3RvbUNsYXNzaWZpZXIiLCJkZWFkbGluZSIsImRlZmVycmVkVW50aWwiLCJkZWxldGVkQXQiLCJkZXRhaWxzIiwiZHYiLCJleGVjdXRvciIsImZlZWRiYWNrQWRkaXRpb25hbE9wdGlvbnMiLCJmZWVkYmFja0NvbW1lbnQiLCJmZWVkYmFja1VwZGF0ZWRBdCIsImZlZWRiYWNrVmFsdWUiLCJmbG9vck5hbWUiLCJpZCIsImlzQXV0b0NsYXNzaWZpZWQiLCJpc0NvbXBsZXRlZEFmdGVyRGVhZGxpbmUiLCJpc0VtZXJnZW5jeSIsImlzSW5zdXJhbmNlIiwiaXNQYWlkIiwiaXNQYXlhYmxlIiwiaXNSZXNpZGVudFRpY2tldCIsImlzV2FycmFudHkiLCJrYW5iYW5Db2x1bW4iLCJrYW5iYW5PcmRlciIsImxhc3RDb21tZW50QXQiLCJsYXN0Q29tbWVudFdpdGhPcmdhbml6YXRpb25UeXBlQXQiLCJsYXN0Q29tbWVudFdpdGhSZXNpZGVudFR5cGVBdCIsImxhc3RDb21tZW50V2l0aFJlc2lkZW50VHlwZUNyZWF0ZWRCeVVzZXJUeXBlIiwibGFzdFJlc2lkZW50Q29tbWVudEF0IiwibWV0YSIsIm5ld0lkIiwibnVtYmVyIiwib3JkZXIiLCJvcmdhbml6YXRpb24iLCJwbGFjZUNsYXNzaWZpZXIiLCJwcmlvcml0eSIsInByb2JsZW1DbGFzc2lmaWVyIiwicHJvcGVydHkiLCJwcm9wZXJ0eUFkZHJlc3MiLCJwcm9wZXJ0eUFkZHJlc3NNZXRhIiwicXVhbGl0eUNvbnRyb2xBZGRpdGlvbmFsT3B0aW9ucyIsInF1YWxpdHlDb250cm9sQ29tbWVudCIsInF1YWxpdHlDb250cm9sVXBkYXRlZEF0IiwicXVhbGl0eUNvbnRyb2xVcGRhdGVkQnkiLCJxdWFsaXR5Q29udHJvbFZhbHVlIiwicmVsYXRlZCIsInJldmlld0NvbW1lbnQiLCJyZXZpZXdWYWx1ZSIsInNlY3Rpb25OYW1lIiwic2VjdGlvblR5cGUiLCJzZW5kZXIiLCJzZW50VG9BdXRob3JpdGllc0F0Iiwic291cmNlIiwic291cmNlTWV0YSIsInN0YXR1cyIsInN0YXR1c1JlYXNvbiIsInN0YXR1c1Jlb3BlbmVkQ291bnRlciIsInN0YXR1c1VwZGF0ZWRBdCIsInRpdGxlIiwidW5pdE5hbWUiLCJ1bml0VHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImNsaWVudEVtYWlsIiwiY2xpZW50TmFtZSIsImNsaWVudFBob25lIiwiZGV0YWlscyIsIm1ldGEiLCJzb3VyY2VNZXRhIiwidGl0bGUiXX0sIlRpY2tldFNvdXJjZSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImlzRGVmYXVsdCIsIm5hbWUiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInR5cGUiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJUaWNrZXRTdGF0dXMiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuYW1lIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ0eXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiVGlja2V0RmlsZSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJmaWxlIiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInRpY2tldCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImZpbGUiXX0sIlRpY2tldENoYW5nZSI6eyJmaWVsZHMiOlsiYWN0dWFsQ3JlYXRpb25EYXRlIiwiYXNzaWduZWVEaXNwbGF5TmFtZUZyb20iLCJhc3NpZ25lZURpc3BsYXlOYW1lVG8iLCJhc3NpZ25lZUlkRnJvbSIsImFzc2lnbmVlSWRUbyIsImNhblJlYWRCeVJlc2lkZW50RnJvbSIsImNhblJlYWRCeVJlc2lkZW50VG8iLCJjbGFzc2lmaWVyRGlzcGxheU5hbWVGcm9tIiwiY2xhc3NpZmllckRpc3BsYXlOYW1lVG8iLCJjbGFzc2lmaWVySWRGcm9tIiwiY2xhc3NpZmllcklkVG8iLCJjbGllbnREaXNwbGF5TmFtZUZyb20iLCJjbGllbnREaXNwbGF5TmFtZVRvIiwiY2xpZW50RW1haWxGcm9tIiwiY2xpZW50RW1haWxUbyIsImNsaWVudElkRnJvbSIsImNsaWVudElkVG8iLCJjbGllbnROYW1lRnJvbSIsImNsaWVudE5hbWVUbyIsImNsaWVudFBob25lRnJvbSIsImNsaWVudFBob25lVG8iLCJjb250YWN0RGlzcGxheU5hbWVGcm9tIiwiY29udGFjdERpc3BsYXlOYW1lVG8iLCJjb250YWN0SWRGcm9tIiwiY29udGFjdElkVG8iLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJjdXN0b21DbGFzc2lmaWVyRnJvbSIsImN1c3RvbUNsYXNzaWZpZXJUbyIsImRlYWRsaW5lRnJvbSIsImRlYWRsaW5lVG8iLCJkZWZlcnJlZFVudGlsRnJvbSIsImRlZmVycmVkVW50aWxUbyIsImRldGFpbHNGcm9tIiwiZGV0YWlsc1RvIiwiZHYiLCJleGVjdXRvckRpc3BsYXlOYW1lRnJvbSIsImV4ZWN1dG9yRGlzcGxheU5hbWVUbyIsImV4ZWN1dG9ySWRGcm9tIiwiZXhlY3V0b3JJZFRvIiwiZmVlZGJhY2tBZGRpdGlvbmFsT3B0aW9uc0Zyb20iLCJmZWVkYmFja0FkZGl0aW9uYWxPcHRpb25zVG8iLCJmZWVkYmFja0NvbW1lbnRGcm9tIiwiZmVlZGJhY2tDb21tZW50VG8iLCJmZWVkYmFja1ZhbHVlRnJvbSIsImZlZWRiYWNrVmFsdWVUbyIsImZsb29yTmFtZUZyb20iLCJmbG9vck5hbWVUbyIsImlkIiwiaXNFbWVyZ2VuY3lGcm9tIiwiaXNFbWVyZ2VuY3lUbyIsImlzSW5zdXJhbmNlRnJvbSIsImlzSW5zdXJhbmNlVG8iLCJpc1BhaWRGcm9tIiwiaXNQYWlkVG8iLCJpc1BheWFibGVGcm9tIiwiaXNQYXlhYmxlVG8iLCJpc1Jlc2lkZW50VGlja2V0RnJvbSIsImlzUmVzaWRlbnRUaWNrZXRUbyIsImlzV2FycmFudHlGcm9tIiwiaXNXYXJyYW50eVRvIiwia2FuYmFuQ29sdW1uRnJvbSIsImthbmJhbkNvbHVtblRvIiwia2FuYmFuT3JkZXJGcm9tIiwia2FuYmFuT3JkZXJUbyIsIm1ldGFGcm9tIiwibWV0YVRvIiwib2JzZXJ2ZXJzRGlzcGxheU5hbWVzRnJvbSIsIm9ic2VydmVyc0Rpc3BsYXlOYW1lc1RvIiwib2JzZXJ2ZXJzSWRzRnJvbSIsIm9ic2VydmVyc0lkc1RvIiwib3JnYW5pemF0aW9uRGlzcGxheU5hbWVGcm9tIiwib3JnYW5pemF0aW9uRGlzcGxheU5hbWVUbyIsIm9yZ2FuaXphdGlvbklkRnJvbSIsIm9yZ2FuaXphdGlvbklkVG8iLCJwcmlvcml0eUZyb20iLCJwcmlvcml0eVRvIiwicHJvcGVydHlBZGRyZXNzRnJvbSIsInByb3BlcnR5QWRkcmVzc01ldGFGcm9tIiwicHJvcGVydHlBZGRyZXNzTWV0YVRvIiwicHJvcGVydHlBZGRyZXNzVG8iLCJwcm9wZXJ0eURpc3BsYXlOYW1lRnJvbSIsInByb3BlcnR5RGlzcGxheU5hbWVUbyIsInByb3BlcnR5SWRGcm9tIiwicHJvcGVydHlJZFRvIiwicXVhbGl0eUNvbnRyb2xBZGRpdGlvbmFsT3B0aW9uc0Zyb20iLCJxdWFsaXR5Q29udHJvbEFkZGl0aW9uYWxPcHRpb25zVG8iLCJxdWFsaXR5Q29udHJvbENvbW1lbnRGcm9tIiwicXVhbGl0eUNvbnRyb2xDb21tZW50VG8iLCJxdWFsaXR5Q29udHJvbFZhbHVlRnJvbSIsInF1YWxpdHlDb250cm9sVmFsdWVUbyIsInJlbGF0ZWREaXNwbGF5TmFtZUZyb20iLCJyZWxhdGVkRGlzcGxheU5hbWVUbyIsInJlbGF0ZWRJZEZyb20iLCJyZWxhdGVkSWRUbyIsInJldmlld0NvbW1lbnRGcm9tIiwicmV2aWV3Q29tbWVudFRvIiwicmV2aWV3VmFsdWVGcm9tIiwicmV2aWV3VmFsdWVUbyIsInNlY3Rpb25OYW1lRnJvbSIsInNlY3Rpb25OYW1lVG8iLCJzZWN0aW9uVHlwZUZyb20iLCJzZWN0aW9uVHlwZVRvIiwic2VuZGVyIiwic2VudFRvQXV0aG9yaXRpZXNBdEZyb20iLCJzZW50VG9BdXRob3JpdGllc0F0VG8iLCJzb3VyY2VEaXNwbGF5TmFtZUZyb20iLCJzb3VyY2VEaXNwbGF5TmFtZVRvIiwic291cmNlSWRGcm9tIiwic291cmNlSWRUbyIsInNvdXJjZU1ldGFGcm9tIiwic291cmNlTWV0YVRvIiwic3RhdHVzRGlzcGxheU5hbWVGcm9tIiwic3RhdHVzRGlzcGxheU5hbWVUbyIsInN0YXR1c0lkRnJvbSIsInN0YXR1c0lkVG8iLCJzdGF0dXNSZWFzb25Gcm9tIiwic3RhdHVzUmVhc29uVG8iLCJzdGF0dXNSZW9wZW5lZENvdW50ZXJGcm9tIiwic3RhdHVzUmVvcGVuZWRDb3VudGVyVG8iLCJ0aWNrZXQiLCJ0aXRsZUZyb20iLCJ0aXRsZVRvIiwidW5pdE5hbWVGcm9tIiwidW5pdE5hbWVUbyIsInVuaXRUeXBlRnJvbSIsInVuaXRUeXBlVG8iLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJjbGllbnRFbWFpbEZyb20iLCJjbGllbnRFbWFpbFRvIiwiY2xpZW50TmFtZUZyb20iLCJjbGllbnROYW1lVG8iLCJjbGllbnRQaG9uZUZyb20iLCJjbGllbnRQaG9uZVRvIiwiZGV0YWlsc0Zyb20iLCJkZXRhaWxzVG8iLCJtZXRhRnJvbSIsIm1ldGFUbyIsInNvdXJjZU1ldGFGcm9tIiwic291cmNlTWV0YVRvIiwidGl0bGVGcm9tIiwidGl0bGVUbyJdfSwiVGlja2V0Q29tbWVudCI6eyJmaWVsZHMiOlsiY29udGVudCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsInNlbmRlciIsInRpY2tldCIsInR5cGUiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiY29udGVudCJdfSwiVGlja2V0UGxhY2VDbGFzc2lmaWVyIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibmFtZSIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiVGlja2V0Q2F0ZWdvcnlDbGFzc2lmaWVyIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibmFtZSIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiVGlja2V0UHJvYmxlbUNsYXNzaWZpZXIiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuYW1lIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJUaWNrZXRDbGFzc2lmaWVyIjp7ImZpZWxkcyI6WyJjYXRlZ29yeSIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInBsYWNlIiwicHJvYmxlbSIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlRpY2tldENvbW1lbnRGaWxlIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImZpbGUiLCJpZCIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwidGlja2V0IiwidGlja2V0Q29tbWVudCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImZpbGUiXX0sIlVzZXJUaWNrZXRDb21tZW50UmVhZFRpbWUiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsInJlYWRDb21tZW50QXQiLCJyZWFkT3JnYW5pemF0aW9uQ29tbWVudEF0IiwicmVhZFJlc2lkZW50Q29tbWVudEF0Iiwic2VuZGVyIiwidGlja2V0IiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlRpY2tldFByb3BlcnR5SGludCI6eyJmaWVsZHMiOlsiY29udGVudCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuYW1lIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJUaWNrZXRQcm9wZXJ0eUhpbnRQcm9wZXJ0eSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwicHJvcGVydHkiLCJzZW5kZXIiLCJ0aWNrZXRQcm9wZXJ0eUhpbnQiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJUaWNrZXRFeHBvcnRUYXNrIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImV4cG9ydGVkUmVjb3Jkc0NvdW50IiwiZmlsZSIsImZvcm1hdCIsImlkIiwibG9jYWxlIiwibWV0YSIsIm5ld0lkIiwib3B0aW9ucyIsInNlbmRlciIsInNvcnRCeSIsInN0YXR1cyIsInRpbWVab25lIiwidG90YWxSZWNvcmRzQ291bnQiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiIsIndoZXJlIl0sInNlbnNpdGl2ZUZpZWxkcyI6WyJmaWxlIiwibWV0YSIsIndoZXJlIl19LCJUaWNrZXRPcmdhbml6YXRpb25TZXR0aW5nIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWZhdWx0RGVhZGxpbmVEdXJhdGlvbiIsImRlbGV0ZWRBdCIsImR2IiwiZW1lcmdlbmN5RGVhZGxpbmVEdXJhdGlvbiIsImlkIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJwYWlkRGVhZGxpbmVEdXJhdGlvbiIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiLCJ3YXJyYW50eURlYWRsaW5lRHVyYXRpb24iXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkluY2lkZW50Ijp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkZXRhaWxzIiwiZHYiLCJoYXNBbGxQcm9wZXJ0aWVzIiwiaWQiLCJuZXdJZCIsIm51bWJlciIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInN0YXR1cyIsInRleHRGb3JSZXNpZGVudCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiLCJ3b3JrRmluaXNoIiwid29ya1N0YXJ0Iiwid29ya1R5cGUiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkluY2lkZW50Q2hhbmdlIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZXRhaWxzRnJvbSIsImRldGFpbHNUbyIsImR2IiwiaWQiLCJpbmNpZGVudCIsIm9yZ2FuaXphdGlvbkRpc3BsYXlOYW1lRnJvbSIsIm9yZ2FuaXphdGlvbkRpc3BsYXlOYW1lVG8iLCJvcmdhbml6YXRpb25JZEZyb20iLCJvcmdhbml6YXRpb25JZFRvIiwic2VuZGVyIiwic3RhdHVzRnJvbSIsInN0YXR1c1RvIiwidGV4dEZvclJlc2lkZW50RnJvbSIsInRleHRGb3JSZXNpZGVudFRvIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiIsIndvcmtGaW5pc2hGcm9tIiwid29ya0ZpbmlzaFRvIiwid29ya1N0YXJ0RnJvbSIsIndvcmtTdGFydFRvIiwid29ya1R5cGVGcm9tIiwid29ya1R5cGVUbyJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiSW5jaWRlbnRQcm9wZXJ0eSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImluY2lkZW50IiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJwcm9wZXJ0eSIsInByb3BlcnR5QWRkcmVzcyIsInByb3BlcnR5QWRkcmVzc01ldGEiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJJbmNpZGVudENsYXNzaWZpZXIiOnsiZmllbGRzIjpbImNhdGVnb3J5IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwicHJvYmxlbSIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkluY2lkZW50Q2xhc3NpZmllckluY2lkZW50Ijp7ImZpZWxkcyI6WyJjbGFzc2lmaWVyIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImluY2lkZW50IiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJVc2VyRmF2b3JpdGVUaWNrZXQiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInRpY2tldCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJJbmNpZGVudEV4cG9ydFRhc2siOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZXhwb3J0ZWRSZWNvcmRzQ291bnQiLCJmaWxlIiwiZm9ybWF0IiwiaWQiLCJsb2NhbGUiLCJtZXRhIiwibmV3SWQiLCJzZW5kZXIiLCJzb3J0QnkiLCJzdGF0dXMiLCJ0aW1lWm9uZSIsInRvdGFsUmVjb3Jkc0NvdW50IiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiLCJ3aGVyZSJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZmlsZSIsIm1ldGEiLCJ3aGVyZSJdfSwiQ2FsbFJlY29yZCI6eyJmaWVsZHMiOlsiY2FsbGVyUGhvbmUiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkZXN0Q2FsbGVyUGhvbmUiLCJkdiIsImZpbGUiLCJpZCIsImltcG9ydElkIiwiaXNJbmNvbWluZ0NhbGwiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInN0YXJ0ZWRBdCIsInRhbGtUaW1lIiwidHJhbnNjcmlwdCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImNhbGxlclBob25lIiwiZGVzdENhbGxlclBob25lIiwiZmlsZSIsInRyYW5zY3JpcHQiXX0sIkNhbGxSZWNvcmRGcmFnbWVudCI6eyJmaWVsZHMiOlsiY2FsbFJlY29yZCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInN0YXJ0ZWRBdCIsInRpY2tldCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlRpY2tldEF1dG9Bc3NpZ25tZW50Ijp7ImZpZWxkcyI6WyJhc3NpZ25lZSIsImNsYXNzaWZpZXIiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImV4ZWN1dG9yIiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlRpY2tldERvY3VtZW50R2VuZXJhdGlvblRhc2siOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImRvY3VtZW50VHlwZSIsImR2IiwiZmlsZSIsImZvcm1hdCIsImlkIiwibWV0YSIsIm5ld0lkIiwicHJvZ3Jlc3MiLCJzZW5kZXIiLCJzdGF0dXMiLCJ0aWNrZXQiLCJ0aW1lWm9uZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJmaWxlIiwibWV0YSJdfSwiVGlja2V0T2JzZXJ2ZXIiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsInNlbmRlciIsInRpY2tldCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJNZXNzYWdlIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkZWxpdmVyZWRBdCIsImR2IiwiZW1haWwiLCJlbWFpbEZyb20iLCJpZCIsImxhbmciLCJtZXRhIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJwaG9uZSIsInByb2Nlc3NpbmdNZXRhIiwicmVhZEF0IiwicmVtb3RlQ2xpZW50Iiwic2VuZGVyIiwic2VudEF0Iiwic3RhdHVzIiwidHlwZSIsInVuaXFLZXkiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZW1haWwiLCJlbWFpbEZyb20iLCJtZXRhIiwicGhvbmUiLCJwcm9jZXNzaW5nTWV0YSJdfSwiUmVtb3RlQ2xpZW50Ijp7ImZpZWxkcyI6WyJhcHBJZCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImRldmljZUlkIiwiZGV2aWNlS2V5IiwiZGV2aWNlUGxhdGZvcm0iLCJkdiIsImlkIiwibWV0YSIsIm5ld0lkIiwib3duZXIiLCJwdXNoVG9rZW4iLCJwdXNoVG9rZW5Wb0lQIiwicHVzaFRyYW5zcG9ydCIsInB1c2hUcmFuc3BvcnRWb0lQIiwicHVzaFR5cGUiLCJwdXNoVHlwZVZvSVAiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJkZXZpY2VLZXkiLCJwdXNoVG9rZW4iLCJwdXNoVG9rZW5Wb0lQIl19LCJNZXNzYWdlVXNlckJsYWNrTGlzdCI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZGVzY3JpcHRpb24iLCJkdiIsImVtYWlsIiwiaWQiLCJuZXdJZCIsInBob25lIiwic2VuZGVyIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJlbWFpbCIsInBob25lIl19LCJNZXNzYWdlT3JnYW5pemF0aW9uQmxhY2tMaXN0Ijp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkZXNjcmlwdGlvbiIsImR2IiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInR5cGUiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJOb3RpZmljYXRpb25Vc2VyU2V0dGluZyI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImlzRW5hYmxlZCIsIm1lc3NhZ2VUcmFuc3BvcnQiLCJtZXNzYWdlVHlwZSIsIm5ld0lkIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlRlbGVncmFtVXNlckNoYXQiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsInNlbmRlciIsInRlbGVncmFtQ2hhdElkIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlJlbW90ZUNsaWVudFB1c2hUb2tlbiI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImlzUHVzaCIsImlzVm9JUCIsIm5ld0lkIiwicHJvdmlkZXIiLCJyZW1vdGVDbGllbnQiLCJzZW5kZXIiLCJ0b2tlbiIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbInRva2VuIl19LCJDb250YWN0Ijp7ImZpZWxkcyI6WyJjb21tdW5pdHlGZWUiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImVtYWlsIiwiaWQiLCJpc1ZlcmlmaWVkIiwibWV0YSIsIm5hbWUiLCJuZXdJZCIsIm5vdGUiLCJvcmdhbml6YXRpb24iLCJvd25lcnNoaXBQZXJjZW50YWdlIiwicGhvbmUiLCJwcm9wZXJ0eSIsInJvbGUiLCJzZW5kZXIiLCJ1bml0TmFtZSIsInVuaXRUeXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZW1haWwiLCJuYW1lIiwibm90ZSIsInBob25lIl19LCJDb250YWN0Um9sZSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5hbWUiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkNvbnRhY3RFeHBvcnRUYXNrIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImV4cG9ydGVkUmVjb3Jkc0NvdW50IiwiZmlsZSIsImZvcm1hdCIsImlkIiwibG9jYWxlIiwibWV0YSIsIm5ld0lkIiwic2VuZGVyIiwic29ydEJ5Iiwic3RhdHVzIiwidGltZVpvbmUiLCJ0b3RhbFJlY29yZHNDb3VudCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Iiwid2hlcmUiXSwic2Vuc2l0aXZlRmllbGRzIjpbImZpbGUiLCJtZXRhIiwid2hlcmUiXX0sIlJlc2lkZW50Ijp7ImZpZWxkcyI6WyJhZGRyZXNzIiwiYWRkcmVzc0tleSIsImFkZHJlc3NNZXRhIiwiYWRkcmVzc1NvdXJjZXMiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJwcm9wZXJ0eSIsInNlbmRlciIsInVuaXROYW1lIiwidW5pdFR5cGUiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiU2VydmljZUNvbnN1bWVyIjp7ImZpZWxkcyI6WyJhY2NvdW50TnVtYmVyIiwiYWNxdWlyaW5nSW50ZWdyYXRpb25Db250ZXh0IiwiYmlsbGluZ0ludGVncmF0aW9uQ29udGV4dCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJpc0Rpc2NvdmVyZWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInBheW1lbnRDYXRlZ29yeSIsInJlc2lkZW50Iiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiVG91clN0ZXAiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsIm9yZGVyIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwic3RhdHVzIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlVzZXJIZWxwUmVxdWVzdCI6eyJmaWVsZHMiOlsiYmlsbGluZ0ludGVncmF0aW9uIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJlbWFpbCIsImlkIiwiaXNSZWFkeVRvU2VuZCIsIm1ldGEiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInBob25lIiwic2VuZGVyIiwic3Vic2NyaXB0aW9uUGxhblByaWNpbmdSdWxlIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImVtYWlsIiwibWV0YSIsInBob25lIl19LCJVc2VySGVscFJlcXVlc3RGaWxlIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImZpbGUiLCJpZCIsIm5ld0lkIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlckhlbHBSZXF1ZXN0IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZmlsZSJdfSwiTWV0ZXJSZXNvdXJjZSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm1lYXN1cmUiLCJuYW1lIiwibmV3SWQiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJNZXRlclJlYWRpbmdTb3VyY2UiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuYW1lIiwibmV3SWQiLCJzZW5kZXIiLCJ0eXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiTWV0ZXJSZWFkaW5nIjp7ImZpZWxkcyI6WyJhY2NvdW50TnVtYmVyIiwiYmlsbGluZ1N0YXR1cyIsImJpbGxpbmdTdGF0dXNUZXh0IiwiY2xpZW50IiwiY2xpZW50RW1haWwiLCJjbGllbnROYW1lIiwiY2xpZW50UGhvbmUiLCJjb250YWN0IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGF0ZSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJtZXRlciIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwic291cmNlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiIsInZhbHVlMSIsInZhbHVlMiIsInZhbHVlMyIsInZhbHVlNCJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiY2xpZW50RW1haWwiLCJjbGllbnROYW1lIiwiY2xpZW50UGhvbmUiXX0sIk1ldGVyIjp7ImZpZWxkcyI6WyJhY2NvdW50TnVtYmVyIiwiYXJjaGl2ZURhdGUiLCJiMmJBcHAiLCJiMmNBcHAiLCJjb21taXNzaW9uaW5nRGF0ZSIsImNvbnRyb2xSZWFkaW5nc0RhdGUiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaW5zdGFsbGF0aW9uRGF0ZSIsImlzQXV0b21hdGljIiwibWV0YSIsIm5ld0lkIiwibmV4dFZlcmlmaWNhdGlvbkRhdGUiLCJudW1iZXIiLCJudW1iZXJPZlRhcmlmZnMiLCJvcmdhbml6YXRpb24iLCJwbGFjZSIsInByb3BlcnR5IiwicmVzb3VyY2UiLCJzZWFsaW5nRGF0ZSIsInNlbmRlciIsInVuaXROYW1lIiwidW5pdFR5cGUiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2IiwidmVyaWZpY2F0aW9uRGF0ZSJdLCJzZW5zaXRpdmVGaWVsZHMiOlsibWV0YSJdfSwiUHJvcGVydHlNZXRlciI6eyJmaWVsZHMiOlsiYXJjaGl2ZURhdGUiLCJiMmJBcHAiLCJjb21taXNzaW9uaW5nRGF0ZSIsImNvbnRyb2xSZWFkaW5nc0RhdGUiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaW5zdGFsbGF0aW9uRGF0ZSIsImlzQXV0b21hdGljIiwibWV0YSIsIm5ld0lkIiwibmV4dFZlcmlmaWNhdGlvbkRhdGUiLCJudW1iZXIiLCJudW1iZXJPZlRhcmlmZnMiLCJvcmdhbml6YXRpb24iLCJwcm9wZXJ0eSIsInJlc291cmNlIiwic2VhbGluZ0RhdGUiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2IiwidmVyaWZpY2F0aW9uRGF0ZSJdLCJzZW5zaXRpdmVGaWVsZHMiOlsibWV0YSJdfSwiUHJvcGVydHlNZXRlclJlYWRpbmciOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRhdGUiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibWV0ZXIiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInNvdXJjZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiLCJ2YWx1ZTEiLCJ2YWx1ZTIiLCJ2YWx1ZTMiLCJ2YWx1ZTQiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIk1ldGVyUmVwb3J0aW5nUGVyaW9kIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibmV3SWQiLCJub3RpZnlFbmREYXkiLCJub3RpZnlTdGFydERheSIsIm9yZ2FuaXphdGlvbiIsInByb3BlcnR5IiwicmVzdHJpY3Rpb25FbmREYXkiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJNZXRlclJlc291cmNlT3duZXIiOnsiZmllbGRzIjpbImFkZHJlc3MiLCJhZGRyZXNzS2V5IiwiYWRkcmVzc01ldGEiLCJhZGRyZXNzU291cmNlcyIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInJlc291cmNlIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiTWV0ZXJSZWFkaW5nc0ltcG9ydFRhc2siOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZXJyb3JGaWxlIiwiZXJyb3JNZXNzYWdlIiwiZmlsZSIsImZvcm1hdCIsImlkIiwiaW1wb3J0ZWRSZWNvcmRzQ291bnQiLCJpc1Byb3BlcnR5TWV0ZXJzIiwibG9jYWxlIiwibWV0YSIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwicHJvY2Vzc2VkUmVjb3Jkc0NvdW50Iiwic2VuZGVyIiwic3RhdHVzIiwidG90YWxSZWNvcmRzQ291bnQiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZXJyb3JGaWxlIiwiZmlsZSIsIm1ldGEiXX0sIk1ldGVyUmVhZGluZ0V4cG9ydFRhc2siOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZXhwb3J0ZWRSZWNvcmRzQ291bnQiLCJmaWxlIiwiZm9ybWF0IiwiaWQiLCJsb2NhbGUiLCJtZXRhIiwibmV3SWQiLCJzZW5kZXIiLCJzb3J0QnkiLCJzdGF0dXMiLCJ0aW1lWm9uZSIsInRvdGFsUmVjb3Jkc0NvdW50IiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiLCJ3aGVyZSJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZmlsZSIsIm1ldGEiLCJ3aGVyZSJdfSwiTWV0ZXJVc2VyU2V0dGluZyI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm1ldGVyIiwibmFtZSIsIm5ld0lkIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlN1YnNjcmlwdGlvblBsYW4iOnsiZmllbGRzIjpbImFpIiwiYW5hbHl0aWNzIiwiY2FuQmVQcm9tb3RlZCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1c3RvbWl6YXRpb24iLCJkZWxldGVkQXQiLCJkZXNjcmlwdGlvbiIsImR2IiwiZW5hYmxlZEIyQkFwcHMiLCJlbmFibGVkQjJDQXBwcyIsImlkIiwiaXNIaWRkZW4iLCJtYXJrZXRwbGFjZSIsIm1ldGVycyIsIm5hbWUiLCJuZXdJZCIsIm5ld3MiLCJvcmdhbml6YXRpb25UeXBlIiwicGF5bWVudHMiLCJwcmlvcml0eSIsInByb3BlcnRpZXMiLCJzZW5kZXIiLCJzdXBwb3J0IiwidGlja2V0cyIsInRyaWFsRGF5cyIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlN1YnNjcmlwdGlvblBsYW5QcmljaW5nUnVsZSI6eyJmaWVsZHMiOlsiY29uZGl0aW9ucyIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1cnJlbmN5Q29kZSIsImRlbGV0ZWRBdCIsImRlc2NyaXB0aW9uIiwiZHYiLCJpZCIsImlzSGlkZGVuIiwibmFtZSIsIm5ld0lkIiwicGVyaW9kIiwicHJpY2UiLCJwcmlvcml0eSIsInNlbmRlciIsInN1YnNjcmlwdGlvblBsYW4iLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJTdWJzY3JpcHRpb25Db250ZXh0Ijp7ImZpZWxkcyI6WyJiaW5kaW5nSWQiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImVuZEF0IiwiZnJvemVuUGF5bWVudEluZm8iLCJpZCIsImludm9pY2UiLCJpc1RyaWFsIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJzdGFydEF0Iiwic3RhdHVzIiwic3Vic2NyaXB0aW9uUGxhbiIsInN1YnNjcmlwdGlvblBsYW5QcmljaW5nUnVsZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImJpbmRpbmdJZCIsImZyb3plblBheW1lbnRJbmZvIl19LCJBY3F1aXJpbmdJbnRlZ3JhdGlvbiI6eyJmaWVsZHMiOlsiY2FuR3JvdXBSZWNlaXB0cyIsImNvbnRleHREZWZhdWx0U3RhdHVzIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJleHBsaWNpdEZlZURpc3RyaWJ1dGlvblNjaGVtYSIsImhvc3RVcmwiLCJpZCIsImlzSGlkZGVuIiwibWF4aW11bVBheW1lbnRBbW91bnQiLCJtaW5pbXVtUGF5bWVudEFtb3VudCIsIm5hbWUiLCJuZXdJZCIsInNlbmRlciIsInNldHVwVXJsIiwic3VwcG9ydGVkQmlsbGluZ0ludGVncmF0aW9uc0dyb3VwIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiLCJ2YXRQZXJjZW50T3B0aW9ucyJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQWNxdWlyaW5nSW50ZWdyYXRpb25BY2Nlc3NSaWdodCI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImludGVncmF0aW9uIiwibmV3SWQiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQWNxdWlyaW5nSW50ZWdyYXRpb25Db250ZXh0Ijp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImVtYWlsIiwiaWQiLCJpbXBsaWNpdEZlZURpc3RyaWJ1dGlvblNjaGVtYSIsImludGVncmF0aW9uIiwiaW52b2ljZUVtYWlscyIsImludm9pY2VJbXBsaWNpdEZlZURpc3RyaWJ1dGlvblNjaGVtYSIsImludm9pY2VSZWFzb24iLCJpbnZvaWNlUmVjaXBpZW50IiwiaW52b2ljZVNhbGVzVGF4UGVyY2VudCIsImludm9pY2VTdGF0dXMiLCJpbnZvaWNlVGF4UmVnaW1lIiwiaW52b2ljZVZhdFBlcmNlbnQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInJlYXNvbiIsInJlY2lwaWVudCIsInNlbmRlciIsInNldHRpbmdzIiwic3RhdGUiLCJzdGF0dXMiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJlbWFpbCIsImludm9pY2VFbWFpbHMiLCJzZXR0aW5ncyIsInN0YXRlIl19LCJNdWx0aVBheW1lbnQiOnsiZmllbGRzIjpbImFtb3VudFdpdGhvdXRFeHBsaWNpdEZlZSIsImNhcmROdW1iZXIiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJjdXJyZW5jeUNvZGUiLCJkZWxldGVkQXQiLCJkdiIsImV4cGxpY2l0RmVlIiwiZXhwbGljaXRTZXJ2aWNlQ2hhcmdlIiwiaWQiLCJpbXBsaWNpdEZlZSIsImltcG9ydElkIiwiaW50ZWdyYXRpb24iLCJtZXRhIiwibmV3SWQiLCJwYXllckVtYWlsIiwicGF5bWVudFdheSIsInJlY3VycmVudFBheW1lbnRDb250ZXh0Iiwic2VuZGVyIiwic2VydmljZUNhdGVnb3J5Iiwic2VydmljZUZlZSIsInN0YXR1cyIsInRyYW5zYWN0aW9uSWQiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiIsIndpdGhkcmF3bkF0Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJtZXRhIiwicGF5ZXJFbWFpbCJdfSwiUGF5bWVudCI6eyJmaWVsZHMiOlsiYWNjb3VudE51bWJlciIsImFkdmFuY2VkQXQiLCJhbW91bnQiLCJjb250ZXh0IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiY3VycmVuY3lDb2RlIiwiZGVsZXRlZEF0IiwiZGVwb3NpdGVkRGF0ZSIsImR2IiwiZXhwbGljaXRGZWUiLCJleHBsaWNpdFNlcnZpY2VDaGFyZ2UiLCJmcm96ZW5EaXN0cmlidXRpb24iLCJmcm96ZW5JbnZvaWNlIiwiZnJvemVuUmVjZWlwdCIsImZyb3plblNwbGl0cyIsImlkIiwiaW1wbGljaXRGZWUiLCJpbXBvcnRJZCIsImludm9pY2UiLCJtdWx0aVBheW1lbnQiLCJuZXdJZCIsIm9yZGVyIiwib3JnYW5pemF0aW9uIiwicGVyaW9kIiwicG9zUmVjZWlwdFVybCIsInB1cnBvc2UiLCJyYXdBZGRyZXNzIiwicmVjZWlwdCIsInJlY2lwaWVudCIsInJlY2lwaWVudEJhbmtBY2NvdW50IiwicmVjaXBpZW50QmljIiwic2VuZGVyIiwic2VydmljZUZlZSIsInN0YXR1cyIsInRyYW5zZmVyRGF0ZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImZyb3plbkRpc3RyaWJ1dGlvbiIsImZyb3plbkludm9pY2UiLCJmcm96ZW5SZWNlaXB0IiwiZnJvemVuU3BsaXRzIiwicG9zUmVjZWlwdFVybCJdfSwiUGF5bWVudFN0YXR1c0NoYW5nZVdlYmhvb2tVcmwiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImRlc2NyaXB0aW9uIiwiZHYiLCJpZCIsImlzRW5hYmxlZCIsIm5hbWUiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVybCIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlJlY3VycmVudFBheW1lbnRDb250ZXh0Ijp7ImZpZWxkcyI6WyJhdXRvUGF5UmVjZWlwdHMiLCJiaWxsaW5nQ2F0ZWdvcnkiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImVuYWJsZWQiLCJpZCIsImxpbWl0IiwibmV3SWQiLCJwYXltZW50RGF5Iiwic2VuZGVyIiwic2VydmljZUNvbnN1bWVyIiwic2V0dGluZ3MiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJzZXR0aW5ncyJdfSwiUHJvcGVydHlTY29wZSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJoYXNBbGxFbXBsb3llZXMiLCJoYXNBbGxQcm9wZXJ0aWVzIiwiaWQiLCJuYW1lIiwibmV3SWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJQcm9wZXJ0eVNjb3BlT3JnYW5pemF0aW9uRW1wbG95ZWUiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZW1wbG95ZWUiLCJpZCIsIm5ld0lkIiwicHJvcGVydHlTY29wZSIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIlByb3BlcnR5U2NvcGVQcm9wZXJ0eSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5ld0lkIiwicHJvcGVydHkiLCJwcm9wZXJ0eVNjb3BlIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiTmV3c0l0ZW0iOnsiZmllbGRzIjpbImJvZHkiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaXNQdWJsaXNoZWQiLCJuZXdJZCIsIm51bWJlciIsIm9yZ2FuaXphdGlvbiIsInB1Ymxpc2hlZEF0Iiwic2VuZEF0Iiwic2VuZGVyIiwic2VudEF0IiwidGl0bGUiLCJ0eXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiIsInZhbGlkQmVmb3JlIl0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJOZXdzSXRlbVNjb3BlIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibmV3SWQiLCJuZXdzSXRlbSIsInByb3BlcnR5Iiwic2VuZGVyIiwidHlwZSIsInVuaXROYW1lIiwidW5pdFR5cGUiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJOZXdzSXRlbVRlbXBsYXRlIjp7ImZpZWxkcyI6WyJib2R5IiwiY2F0ZWdvcnkiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibmFtZSIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwidGl0bGUiLCJ0eXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiTmV3c0l0ZW1Vc2VyUmVhZCI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5ld0lkIiwibmV3c0l0ZW0iLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiTmV3c0l0ZW1SZWNpcGllbnRzRXhwb3J0VGFzayI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJmaWxlIiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInNjb3BlcyIsInNlbmRlciIsInN0YXR1cyIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInVzZXIiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJmaWxlIl19LCJOZXdzSXRlbVNoYXJpbmciOnsiZmllbGRzIjpbImIyYkFwcENvbnRleHQiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibGFzdFBvc3RSZXF1ZXN0IiwibmV3SWQiLCJuZXdzSXRlbSIsInNlbmRlciIsInNoYXJpbmdQYXJhbXMiLCJzdGF0dXMiLCJzdGF0dXNNZXNzYWdlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsibGFzdFBvc3RSZXF1ZXN0Iiwic2hhcmluZ1BhcmFtcyJdfSwiTmV3c0l0ZW1GaWxlIjp7ImZpZWxkcyI6WyJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImZpbGUiLCJpZCIsIm5ld0lkIiwibmV3c0l0ZW0iLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJCMkJBcHAiOnsiZmllbGRzIjpbImFkZGl0aW9uYWxEb21haW5zIiwiYXBwVXJsIiwiY2F0ZWdvcnkiLCJjb250ZXh0RGVmYXVsdFN0YXR1cyIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImRldGFpbGVkRGVzY3JpcHRpb24iLCJkZXZlbG9wZXIiLCJkZXZlbG9wZXJVcmwiLCJkaXNwbGF5UHJpb3JpdHkiLCJkdiIsImZlYXR1cmVzIiwiZ2FsbGVyeSIsImhhc0R5bmFtaWNUaXRsZSIsImljb24iLCJpZCIsImltcG9ydElkIiwiaW1wb3J0UmVtb3RlU3lzdGVtIiwiaXNHbG9iYWwiLCJpc0hpZGRlbiIsImlzUHVibGljIiwibGFiZWwiLCJsb2dvIiwibWVudUNhdGVnb3J5IiwibmFtZSIsIm5ld0lkIiwibmV3c1NoYXJpbmdDb25maWciLCJvaWRjQ2xpZW50IiwicG9zSW50ZWdyYXRpb25Db25maWciLCJwcmljZSIsInNlbmRlciIsInNob3J0RGVzY3JpcHRpb24iLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJCMkJBcHBDb250ZXh0Ijp7ImZpZWxkcyI6WyJhcHAiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibWV0YSIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwic3RhdHVzIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsibWV0YSJdfSwiQjJCQXBwQWNjZXNzUmlnaHQiOnsiZmllbGRzIjpbImFjY2Vzc1JpZ2h0U2V0IiwiYXBwIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5ld0lkIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkIyQ0FwcCI6eyJmaWVsZHMiOlsiYWRkaXRpb25hbERvbWFpbnMiLCJhcHBVcmwiLCJjb2xvclNjaGVtYSIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1cnJlbnRCdWlsZCIsImRlbGV0ZWRBdCIsImRldmVsb3BlciIsImR2IiwiaWQiLCJpbXBvcnRJZCIsImltcG9ydFJlbW90ZVN5c3RlbSIsImlzSGlkZGVuIiwibG9nbyIsIm5hbWUiLCJuZXdJZCIsIm9pZGNDbGllbnQiLCJzZW5kZXIiLCJzaG9ydERlc2NyaXB0aW9uIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQjJDQXBwQWNjZXNzUmlnaHQiOnsiZmllbGRzIjpbImFjY2Vzc1JpZ2h0U2V0IiwiYXBwIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImltcG9ydElkIiwiaW1wb3J0UmVtb3RlU3lzdGVtIiwibmV3SWQiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQjJDQXBwQnVpbGQiOnsiZmllbGRzIjpbImFwcCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRhdGEiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwiaW1wb3J0SWQiLCJpbXBvcnRSZW1vdGVTeXN0ZW0iLCJuZXdJZCIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiLCJ2ZXJzaW9uIl0sInNlbnNpdGl2ZUZpZWxkcyI6WyJkYXRhIl19LCJCMkNBcHBQcm9wZXJ0eSI6eyJmaWVsZHMiOlsiYWRkcmVzcyIsImFkZHJlc3NLZXkiLCJhZGRyZXNzTWV0YSIsImFkZHJlc3NTb3VyY2VzIiwiYXBwIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5ld0lkIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQjJCQXBwUHJvbW9CbG9jayI6eyJmaWVsZHMiOlsiYmFja2dyb3VuZENvbG9yIiwiYmFja2dyb3VuZEltYWdlIiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJleHRlcm5hbCIsImlkIiwibmV3SWQiLCJwcmlvcml0eSIsInNlbmRlciIsInN1YnRpdGxlIiwidGFyZ2V0VXJsIiwidGV4dFZhcmlhbnQiLCJ0aXRsZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkIyQkFwcFBlcm1pc3Npb24iOnsiZmllbGRzIjpbImFwcCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJrZXkiLCJuYW1lIiwibmV3SWQiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJCMkJBcHBSb2xlIjp7ImZpZWxkcyI6WyJhcHAiLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibmV3SWQiLCJwZXJtaXNzaW9ucyIsInJvbGUiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJCMkJBcHBBY2Nlc3NSaWdodFNldCI6eyJmaWVsZHMiOlsiYXBwIiwiY2FuRXhlY3V0ZVJlZ2lzdGVyQmlsbGluZ1JlY2VpcHRGaWxlIiwiY2FuRXhlY3V0ZVJlZ2lzdGVyQmlsbGluZ1JlY2VpcHRzIiwiY2FuRXhlY3V0ZVJlZ2lzdGVyTWV0ZXJzUmVhZGluZ3MiLCJjYW5FeGVjdXRlUmVnaXN0ZXJQcm9wZXJ0eU1ldGVyc1JlYWRpbmdzIiwiY2FuRXhlY3V0ZVNlbmRCMkJBcHBQdXNoTWVzc2FnZSIsImNhbkV4ZWN1dGVTZXRQYXltZW50UG9zUmVjZWlwdFVybCIsImNhbk1hbmFnZUIyQkFjY2Vzc1Rva2VucyIsImNhbk1hbmFnZUJpbGxpbmdBY2NvdW50cyIsImNhbk1hbmFnZUJpbGxpbmdJbnRlZ3JhdGlvbk9yZ2FuaXphdGlvbkNvbnRleHRzIiwiY2FuTWFuYWdlQmlsbGluZ1Byb3BlcnRpZXMiLCJjYW5NYW5hZ2VCaWxsaW5nUmVjZWlwdEZpbGVzIiwiY2FuTWFuYWdlQmlsbGluZ1JlY2VpcHRzIiwiY2FuTWFuYWdlQmlsbGluZ1JlY2lwaWVudHMiLCJjYW5NYW5hZ2VDb250YWN0cyIsImNhbk1hbmFnZUN1c3RvbVZhbHVlcyIsImNhbk1hbmFnZUludm9pY2VzIiwiY2FuTWFuYWdlTWV0ZXJSZWFkaW5ncyIsImNhbk1hbmFnZU1ldGVyUmVwb3J0aW5nUGVyaW9kcyIsImNhbk1hbmFnZU1ldGVycyIsImNhbk1hbmFnZU9yZ2FuaXphdGlvbkVtcGxveWVlUm9sZXMiLCJjYW5NYW5hZ2VPcmdhbml6YXRpb25FbXBsb3llZXMiLCJjYW5NYW5hZ2VPcmdhbml6YXRpb25zIiwiY2FuTWFuYWdlUGF5bWVudHMiLCJjYW5NYW5hZ2VQcm9wZXJ0aWVzIiwiY2FuTWFuYWdlVGlja2V0Q29tbWVudEZpbGVzIiwiY2FuTWFuYWdlVGlja2V0Q29tbWVudHMiLCJjYW5NYW5hZ2VUaWNrZXRGaWxlcyIsImNhbk1hbmFnZVRpY2tldHMiLCJjYW5SZWFkQjJCQWNjZXNzVG9rZW5zIiwiY2FuUmVhZEJpbGxpbmdBY2NvdW50cyIsImNhblJlYWRCaWxsaW5nSW50ZWdyYXRpb25Pcmdhbml6YXRpb25Db250ZXh0cyIsImNhblJlYWRCaWxsaW5nUHJvcGVydGllcyIsImNhblJlYWRCaWxsaW5nUmVjZWlwdEZpbGVzIiwiY2FuUmVhZEJpbGxpbmdSZWNlaXB0cyIsImNhblJlYWRCaWxsaW5nUmVjaXBpZW50cyIsImNhblJlYWRDb250YWN0cyIsImNhblJlYWRDdXN0b21WYWx1ZXMiLCJjYW5SZWFkSW52b2ljZXMiLCJjYW5SZWFkTWV0ZXJSZWFkaW5ncyIsImNhblJlYWRNZXRlclJlcG9ydGluZ1BlcmlvZHMiLCJjYW5SZWFkTWV0ZXJzIiwiY2FuUmVhZE9yZ2FuaXphdGlvbkVtcGxveWVlUm9sZXMiLCJjYW5SZWFkT3JnYW5pemF0aW9uRW1wbG95ZWVzIiwiY2FuUmVhZE9yZ2FuaXphdGlvbnMiLCJjYW5SZWFkUGF5bWVudHMiLCJjYW5SZWFkUHJvcGVydGllcyIsImNhblJlYWRUaWNrZXRDb21tZW50RmlsZXMiLCJjYW5SZWFkVGlja2V0Q29tbWVudHMiLCJjYW5SZWFkVGlja2V0RmlsZXMiLCJjYW5SZWFkVGlja2V0cyIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuYW1lIiwibmV3SWQiLCJzZW5kZXIiLCJ0eXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQjJCQXBwTmV3c1NoYXJpbmdDb25maWciOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImN1c3RvbUZvcm1VcmwiLCJkZWxldGVkQXQiLCJkdiIsImdldFJlY2lwaWVudHNDb3VudGVyc1VybCIsImdldFJlY2lwaWVudHNVcmwiLCJpY29uIiwiaWQiLCJuYW1lIiwibmV3SWQiLCJwcmV2aWV3UGljdHVyZSIsInByZXZpZXdVcmwiLCJwdWJsaXNoVXJsIiwicHVzaE5vdGlmaWNhdGlvblNldHRpbmdzIiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiY3VzdG9tRm9ybVVybCIsImdldFJlY2lwaWVudHNDb3VudGVyc1VybCIsImdldFJlY2lwaWVudHNVcmwiLCJwcmV2aWV3VXJsIiwicHVibGlzaFVybCIsInB1c2hOb3RpZmljYXRpb25TZXR0aW5ncyJdfSwiQXBwTWVzc2FnZVNldHRpbmciOnsiZmllbGRzIjpbImIyYkFwcCIsImIyY0FwcCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsIm5vdGlmaWNhdGlvbldpbmRvd1NpemUiLCJudW1iZXJPZk5vdGlmaWNhdGlvbkluV2luZG93IiwicmVhc29uIiwic2VuZGVyIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkIyQkFjY2Vzc1Rva2VuIjp7ImZpZWxkcyI6WyJjb250ZXh0IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJleHBpcmVzQXQiLCJpZCIsIm5ld0lkIiwicmlnaHRTZXQiLCJzZW5kZXIiLCJzZXNzaW9uSWQiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ1c2VyIiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsic2Vzc2lvbklkIl19LCJDdXN0b21GaWVsZCI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImlzVW5pcXVlUGVyT2JqZWN0IiwiaXNWaXNpYmxlIiwibG9jYWxlIiwibW9kZWxOYW1lIiwibmFtZSIsIm5ld0lkIiwicHJpb3JpdHkiLCJzZW5kZXIiLCJzdGFmZkNhblJlYWQiLCJ0eXBlIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiIsInZhbGlkYXRpb25SdWxlcyJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiQjJCQXBwUG9zSW50ZWdyYXRpb25Db25maWciOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZmV0Y2hMYXN0UG9zUmVjZWlwdFVybCIsImlkIiwibmV3SWQiLCJwYXltZW50c0FsZXJ0UGFnZVVybCIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImZldGNoTGFzdFBvc1JlY2VpcHRVcmwiLCJwYXltZW50c0FsZXJ0UGFnZVVybCJdfSwiQjJDQXBwQWNjZXNzUmlnaHRTZXQiOnsiZmllbGRzIjpbImFwcCIsImNhbkV4ZWN1dGVTZW5kVm9JUFN0YXJ0TWVzc2FnZSIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIk1vYmlsZUZlYXR1cmVDb25maWciOnsiZmllbGRzIjpbImNvbW1vblBob25lIiwiY29udGVudENvbmZpZ3VyYXRpb24iLCJjcmVhdGVkQXQiLCJjcmVhdGVkQnkiLCJkZWxldGVkQXQiLCJkdiIsImlkIiwibWV0YSIsIm5ld0lkIiwib25seUdyZWF0ZXJUaGFuUHJldmlvdXNNZXRlclJlYWRpbmdJc0VuYWJsZWQiLCJvcmdhbml6YXRpb24iLCJzZW5kZXIiLCJ0aWNrZXRTdWJtaXR0aW5nSXNEaXNhYmxlZCIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImNvbW1vblBob25lIl19LCJNYXJrZXRDYXRlZ29yeSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsImltYWdlIiwibW9iaWxlU2V0dGluZ3MiLCJuYW1lIiwibmV3SWQiLCJvcmRlciIsInBhcmVudENhdGVnb3J5Iiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsibW9iaWxlU2V0dGluZ3MiXX0sIk1hcmtldEl0ZW0iOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImRlc2NyaXB0aW9uIiwiZHYiLCJpZCIsIm1hcmtldENhdGVnb3J5IiwibmFtZSIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwic2VuZGVyIiwic2t1IiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOltdfSwiSW52b2ljZSI6eyJmaWVsZHMiOlsiYWNjb3VudE51bWJlciIsImFtb3VudERpc3RyaWJ1dGlvbiIsImNhbmNlbGVkQXQiLCJjbGllbnQiLCJjbGllbnROYW1lIiwiY2xpZW50UGhvbmUiLCJjb250YWN0IiwiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJpZCIsIm5ld0lkIiwibnVtYmVyIiwib3JnYW5pemF0aW9uIiwicGFpZEF0IiwicGF5ZXJPcmdhbml6YXRpb24iLCJwYXltZW50U3RhdHVzQ2hhbmdlV2ViaG9va1NlY3JldCIsInBheW1lbnRTdGF0dXNDaGFuZ2VXZWJob29rVXJsIiwicGF5bWVudFR5cGUiLCJwcm9wZXJ0eSIsInB1Ymxpc2hlZEF0Iiwicm93cyIsInNlbmRlciIsInN0YXR1cyIsInRpY2tldCIsInRvUGF5IiwidHlwZSIsInVuaXROYW1lIiwidW5pdFR5cGUiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6WyJhbW91bnREaXN0cmlidXRpb24iLCJjbGllbnROYW1lIiwiY2xpZW50UGhvbmUiLCJwYXltZW50U3RhdHVzQ2hhbmdlV2ViaG9va1NlY3JldCIsInBheW1lbnRTdGF0dXNDaGFuZ2VXZWJob29rVXJsIl19LCJNYXJrZXRJdGVtRmlsZSI6eyJmaWVsZHMiOlsiY3JlYXRlZEF0IiwiY3JlYXRlZEJ5IiwiZGVsZXRlZEF0IiwiZHYiLCJmaWxlIiwiaWQiLCJtYXJrZXRJdGVtIiwibmV3SWQiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJNYXJrZXRJdGVtUHJpY2UiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJtYXJrZXRJdGVtIiwibmV3SWQiLCJwcmljZSIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIk1hcmtldFByaWNlU2NvcGUiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJtYXJrZXRJdGVtUHJpY2UiLCJuZXdJZCIsInByb3BlcnR5Iiwic2VuZGVyIiwidHlwZSIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIk1hcmtldFNldHRpbmciOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInJlc2lkZW50QWxsb3dlZFBheW1lbnRUeXBlcyIsInNlbmRlciIsInVwZGF0ZWRBdCIsInVwZGF0ZWRCeSIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbXX0sIkRvY3VtZW50Q2F0ZWdvcnkiOnsiZmllbGRzIjpbImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiaWQiLCJuYW1lIiwibmV3SWQiLCJzZW5kZXIiLCJ1cGRhdGVkQXQiLCJ1cGRhdGVkQnkiLCJ2Il0sInNlbnNpdGl2ZUZpZWxkcyI6W119LCJEb2N1bWVudCI6eyJmaWVsZHMiOlsiY2FuUmVhZEJ5UmVzaWRlbnQiLCJjYXRlZ29yeSIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZmlsZSIsImlkIiwibWV0YSIsIm5hbWUiLCJuZXdJZCIsIm9yZ2FuaXphdGlvbiIsInByb3BlcnR5Iiwic2VuZGVyIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidiJdLCJzZW5zaXRpdmVGaWVsZHMiOlsiZmlsZSIsIm1ldGEiXX0sIkV4ZWN1dGlvbkFJRmxvd1Rhc2siOnsiZmllbGRzIjpbImFpU2Vzc2lvbklkIiwiY2xlYW5Db250ZXh0IiwiY29udGV4dCIsImNyZWF0ZWRBdCIsImNyZWF0ZWRCeSIsImRlbGV0ZWRBdCIsImR2IiwiZXJyb3IiLCJlcnJvck1lc3NhZ2UiLCJmbG93VHlwZSIsImlkIiwiaXRlbUlkIiwibG9jYWxlIiwibWV0YSIsIm1vZGVsTmFtZSIsIm5ld0lkIiwib3JnYW5pemF0aW9uIiwicmVzdWx0Iiwic2VuZGVyIiwic3RhdHVzIiwidXBkYXRlZEF0IiwidXBkYXRlZEJ5IiwidXNlciIsInYiXSwic2Vuc2l0aXZlRmllbGRzIjpbImNsZWFuQ29udGV4dCIsImNvbnRleHQiLCJtZXRhIiwicmVzdWx0Il19fX0= + +exports.up = async (knex) => { + await knex.raw(` + BEGIN; +-- +-- Add field canExecuteSendVoIPStartMessage to b2cappaccessrightset +-- +ALTER TABLE "B2CAppAccessRightSet" ADD COLUMN "canExecuteSendVoIPStartMessage" boolean DEFAULT false NOT NULL; +ALTER TABLE "B2CAppAccessRightSet" ALTER COLUMN "canExecuteSendVoIPStartMessage" DROP DEFAULT; +-- +-- Add field canExecuteSendVoIPStartMessage to b2cappaccessrightsethistoryrecord +-- +ALTER TABLE "B2CAppAccessRightSetHistoryRecord" ADD COLUMN "canExecuteSendVoIPStartMessage" boolean NULL; +-- +-- Recreate view for "B2CAppAccessRightSet" table +-- +DROP VIEW IF EXISTS "analytics"."B2CAppAccessRightSet"; +CREATE VIEW "analytics"."B2CAppAccessRightSet" AS (SELECT "app", "canExecuteSendVoIPStartMessage", "createdAt", "createdBy", "deletedAt", "dv", "id", "newId", "sender", "updatedAt", "updatedBy", "v" FROM "public"."B2CAppAccessRightSet"); + +COMMIT; + + `) +} + +exports.down = async (knex) => { + await knex.raw(` + BEGIN; +-- +-- Add field canExecuteSendVoIPStartMessage to b2cappaccessrightsethistoryrecord +-- +ALTER TABLE "B2CAppAccessRightSetHistoryRecord" DROP COLUMN "canExecuteSendVoIPStartMessage" CASCADE; +-- +-- Add field canExecuteSendVoIPStartMessage to b2cappaccessrightset +-- +ALTER TABLE "B2CAppAccessRightSet" DROP COLUMN "canExecuteSendVoIPStartMessage" CASCADE; +-- +-- Recreate view for "B2CAppAccessRightSet" table +-- +DROP VIEW IF EXISTS "analytics"."B2CAppAccessRightSet"; +CREATE VIEW "analytics"."B2CAppAccessRightSet" AS (SELECT "app", "createdAt", "createdBy", "deletedAt", "dv", "id", "newId", "sender", "updatedAt", "updatedBy", "v" FROM "public"."B2CAppAccessRightSet"); + +COMMIT; + + `) +} diff --git a/apps/condo/schema.graphql b/apps/condo/schema.graphql index d7aef5400f6..5638759c58e 100644 --- a/apps/condo/schema.graphql +++ b/apps/condo/schema.graphql @@ -83298,6 +83298,7 @@ type B2CAppAccessRightSetHistoryRecord { """ _label_: String app: String + canExecuteSendVoIPStartMessage: Boolean id: ID! v: Int createdAt: String @@ -83320,6 +83321,8 @@ input B2CAppAccessRightSetHistoryRecordWhereInput { app_not: String app_in: [String] app_not_in: [String] + canExecuteSendVoIPStartMessage: Boolean + canExecuteSendVoIPStartMessage_not: Boolean id: ID id_not: ID id_in: [ID] @@ -83403,6 +83406,8 @@ input B2CAppAccessRightSetHistoryRecordWhereUniqueInput { } enum SortB2CAppAccessRightSetHistoryRecordsBy { + canExecuteSendVoIPStartMessage_ASC + canExecuteSendVoIPStartMessage_DESC id_ASC id_DESC v_ASC @@ -83423,6 +83428,7 @@ enum SortB2CAppAccessRightSetHistoryRecordsBy { input B2CAppAccessRightSetHistoryRecordUpdateInput { app: String + canExecuteSendVoIPStartMessage: Boolean v: Int createdAt: String updatedAt: String @@ -83444,6 +83450,7 @@ input B2CAppAccessRightSetHistoryRecordsUpdateInput { input B2CAppAccessRightSetHistoryRecordCreateInput { app: String + canExecuteSendVoIPStartMessage: Boolean v: Int createdAt: String updatedAt: String @@ -83478,6 +83485,7 @@ type B2CAppAccessRightSet { """ Link to B2CApp """ app: B2CApp + canExecuteSendVoIPStartMessage: Boolean id: ID! v: Int createdAt: String @@ -83506,6 +83514,8 @@ input B2CAppAccessRightSetWhereInput { OR: [B2CAppAccessRightSetWhereInput] app: B2CAppWhereInput app_is_null: Boolean + canExecuteSendVoIPStartMessage: Boolean + canExecuteSendVoIPStartMessage_not: Boolean id: ID id_not: ID id_in: [ID] @@ -83571,6 +83581,8 @@ input B2CAppAccessRightSetWhereUniqueInput { enum SortB2CAppAccessRightSetsBy { app_ASC app_DESC + canExecuteSendVoIPStartMessage_ASC + canExecuteSendVoIPStartMessage_DESC id_ASC id_DESC v_ASC @@ -83591,6 +83603,7 @@ enum SortB2CAppAccessRightSetsBy { input B2CAppAccessRightSetUpdateInput { app: B2CAppRelateToOneInput + canExecuteSendVoIPStartMessage: Boolean v: Int createdAt: String updatedAt: String @@ -83609,6 +83622,7 @@ input B2CAppAccessRightSetsUpdateInput { input B2CAppAccessRightSetCreateInput { app: B2CAppRelateToOneInput + canExecuteSendVoIPStartMessage: Boolean v: Int createdAt: String updatedAt: String @@ -93761,7 +93775,7 @@ input SendVoIPStartMessageDataForCallHandlingByNative { """ Type of the native client to handle call. Values defined in mobile app. "0" used as legacy "sip" voipType for backwards compatibility. """ - voipType: Int + voipType: Int = 0 } input SendVoIPStartMessageData { @@ -106733,6 +106747,12 @@ type Mutation { "voipDtfmCommand": { "required": false }, + "voipPanels": { + "required": false + }, + "stunServers": { + "required": false + }, "stun": { "required": false }, diff --git a/apps/condo/schema.ts b/apps/condo/schema.ts index d5c971fa8b8..7ff7818dae0 100644 --- a/apps/condo/schema.ts +++ b/apps/condo/schema.ts @@ -8294,6 +8294,7 @@ export type B2CAppAccessRightSet = { _label_?: Maybe; /** Link to B2CApp */ app?: Maybe; + canExecuteSendVoIPStartMessage?: Maybe; createdAt?: Maybe; /** Identifies a user, which has created this record. It is a technical connection, that can represent real users, as well as automated systems (bots, scripts). This field should not participate in business logic. */ createdBy?: Maybe; @@ -8312,6 +8313,7 @@ export type B2CAppAccessRightSet = { export type B2CAppAccessRightSetCreateInput = { app?: InputMaybe; + canExecuteSendVoIPStartMessage?: InputMaybe; createdAt?: InputMaybe; createdBy?: InputMaybe; deletedAt?: InputMaybe; @@ -8335,6 +8337,7 @@ export type B2CAppAccessRightSetHistoryRecord = { */ _label_?: Maybe; app?: Maybe; + canExecuteSendVoIPStartMessage?: Maybe; createdAt?: Maybe; createdBy?: Maybe; deletedAt?: Maybe; @@ -8352,6 +8355,7 @@ export type B2CAppAccessRightSetHistoryRecord = { export type B2CAppAccessRightSetHistoryRecordCreateInput = { app?: InputMaybe; + canExecuteSendVoIPStartMessage?: InputMaybe; createdAt?: InputMaybe; createdBy?: InputMaybe; deletedAt?: InputMaybe; @@ -8374,6 +8378,7 @@ export enum B2CAppAccessRightSetHistoryRecordHistoryActionType { export type B2CAppAccessRightSetHistoryRecordUpdateInput = { app?: InputMaybe; + canExecuteSendVoIPStartMessage?: InputMaybe; createdAt?: InputMaybe; createdBy?: InputMaybe; deletedAt?: InputMaybe; @@ -8395,6 +8400,8 @@ export type B2CAppAccessRightSetHistoryRecordWhereInput = { app_in?: InputMaybe>>; app_not?: InputMaybe; app_not_in?: InputMaybe>>; + canExecuteSendVoIPStartMessage?: InputMaybe; + canExecuteSendVoIPStartMessage_not?: InputMaybe; createdAt?: InputMaybe; createdAt_gt?: InputMaybe; createdAt_gte?: InputMaybe; @@ -8495,6 +8502,7 @@ export type B2CAppAccessRightSetRelateToOneInput = { export type B2CAppAccessRightSetUpdateInput = { app?: InputMaybe; + canExecuteSendVoIPStartMessage?: InputMaybe; createdAt?: InputMaybe; createdBy?: InputMaybe; deletedAt?: InputMaybe; @@ -8511,6 +8519,8 @@ export type B2CAppAccessRightSetWhereInput = { OR?: InputMaybe>>; app?: InputMaybe; app_is_null?: InputMaybe; + canExecuteSendVoIPStartMessage?: InputMaybe; + canExecuteSendVoIPStartMessage_not?: InputMaybe; createdAt?: InputMaybe; createdAt_gt?: InputMaybe; createdAt_gte?: InputMaybe; @@ -49953,6 +49963,12 @@ export type Mutation = { * "voipDtfmCommand": { * "required": false * }, + * "voipPanels": { + * "required": false + * }, + * "stunServers": { + * "required": false + * }, * "stun": { * "required": false * }, @@ -89848,10 +89864,7 @@ export type SendVoIpStartMessageData = { * F.e. to cancel calls with CANCELED_CALL_MESSAGE_PUSH messages */ callId: Scalars['String']['input']; - /** - * If you want mobile app to handle call (without your B2CApp), provide this argument. If "b2cAppCallData" and "nativeCallData" are provided together, native call is prioritized. - * If data is incorrect, mobile app will fallback to b2c app call handling with "b2cAppCallData" - */ + /** If you want mobile app to handle call (without your B2CApp), provide this argument. If "b2cAppCallData" and "nativeCallData" are provided together, native call is prioritized. */ nativeCallData?: InputMaybe; }; @@ -89873,6 +89886,8 @@ export type SendVoIpStartMessageDataForCallHandlingByNative = { voipPanels: Array; /** Password for connection to sip server */ voipPassword: Scalars['String']['input']; + /** Type of the native client to handle call. Values defined in mobile app. "0" used as legacy "sip" voipType for backwards compatibility. */ + voipType?: InputMaybe; }; export type SendVoIpStartMessageInput = { @@ -91495,6 +91510,8 @@ export enum SortB2CAppAccessRightHistoryRecordsBy { } export enum SortB2CAppAccessRightSetHistoryRecordsBy { + CanExecuteSendVoIpStartMessageAsc = 'canExecuteSendVoIPStartMessage_ASC', + CanExecuteSendVoIpStartMessageDesc = 'canExecuteSendVoIPStartMessage_DESC', CreatedAtAsc = 'createdAt_ASC', CreatedAtDesc = 'createdAt_DESC', DeletedAtAsc = 'deletedAt_ASC', @@ -91516,6 +91533,8 @@ export enum SortB2CAppAccessRightSetHistoryRecordsBy { export enum SortB2CAppAccessRightSetsBy { AppAsc = 'app_ASC', AppDesc = 'app_DESC', + CanExecuteSendVoIpStartMessageAsc = 'canExecuteSendVoIPStartMessage_ASC', + CanExecuteSendVoIpStartMessageDesc = 'canExecuteSendVoIPStartMessage_DESC', CreatedAtAsc = 'createdAt_ASC', CreatedAtDesc = 'createdAt_DESC', CreatedByAsc = 'createdBy_ASC', From 65e18c4bd55bf628771f6a54d6fb966b3cc42258 Mon Sep 17 00:00:00 2001 From: YEgorLu Date: Thu, 23 Apr 2026 13:30:46 +0500 Subject: [PATCH 6/6] fix(condo): DOMA-12905 after review fixes --- .../schema/B2CAppAccessRightSet.test.js | 2 +- .../schema/SendVoIPStartMessageService.js | 55 +++++++++---------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js b/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js index 463ac8b0c49..3a091279abc 100644 --- a/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js +++ b/apps/condo/domains/miniapp/schema/B2CAppAccessRightSet.test.js @@ -14,11 +14,11 @@ const { const { B2CAppAccessRightSet, createTestB2CAppAccessRightSet, updateTestB2CAppAccessRightSet, createTestB2CAppProperty, updateTestB2CAppProperty, B2CAppProperty, sendVoIPStartMessageByTestClient, updateTestB2CAppAccessRight } = require('@condo/domains/miniapp/utils/testSchema') const { createTestB2CApp, createTestB2CAppAccessRight } = require('@condo/domains/miniapp/utils/testSchema') +const { FLAT_UNIT_TYPE } = require('@condo/domains/property/constants/common') const { buildFakeAddressAndMeta } = require('@condo/domains/property/utils/testSchema/factories') const { makeClientWithNewRegisteredAndLoggedInUser, makeClientWithSupportUser } = require('@condo/domains/user/utils/testSchema') const { makeClientWithServiceUser } = require('@condo/domains/user/utils/testSchema') -const { FLAT_UNIT_TYPE } = require('../../property/constants/common') describe('B2CAppAccessRightSet', () => { let admin diff --git a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js index 567ac023bcb..0e38e8067bf 100644 --- a/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js +++ b/apps/condo/domains/miniapp/schema/SendVoIPStartMessageService.js @@ -124,13 +124,12 @@ function getInitialLogContext ({ addressKey, unitName, unitType, app, callData } } } -async function sendMessageToActor ({ - context, actor, +async function sendMessageToUser ({ + context, resident, contact, user, customVoIPValuesByContactId, sender, dv, b2cApp: { id: b2cAppId, name: b2cAppName }, callData, }) { - const { resident, contact, user } = actor const customVoIPValues = customVoIPValuesByContactId[contact.id] || {} // NOTE(YEgorLu): as in domains/notification/constants/config for VOIP_INCOMING_CALL_MESSAGE_TYPE @@ -188,11 +187,11 @@ async function sendMessageToActor ({ } const result = await sendMessage(context, messageAttrs) - return { actor, result } + return { resident, contact, user, result } } -async function getActors ({ context, logContext, addressKey, unitName, unitType }) { - let actors = [] +async function getVerifiedResidentsWithContacts ({ context, logContext, addressKey, unitName, unitType }) { + let verifiedResidentsWithContacts = [] const oldestProperty = await getOldestNonDeletedProperty({ addressKey }) logContext.logInfoStats.step = 'find property' @@ -201,7 +200,7 @@ async function getActors ({ context, logContext, addressKey, unitName, unitType if (!oldestProperty?.id) { logInfo(logContext) return { - actors, + verifiedResidentsWithContacts, earlyReturnValue: { verifiedContactsCount: 0, createdMessagesCount: 0, @@ -223,7 +222,7 @@ async function getActors ({ context, logContext, addressKey, unitName, unitType if (!allVerifiedContactsOnUnit.length) { logInfo(logContext) return { - actors, + verifiedResidentsWithContacts, earlyReturnValue: { verifiedContactsCount: 0, createdMessagesCount: 0, @@ -244,7 +243,7 @@ async function getActors ({ context, logContext, addressKey, unitName, unitType if (!allResidentsOnUnit.length) { logInfo(logContext) return { - actors, + verifiedResidentsWithContacts, earlyReturnValue: { verifiedContactsCount: allVerifiedContactsOnUnit.length, createdMessagesCount: 0, @@ -253,28 +252,28 @@ async function getActors ({ context, logContext, addressKey, unitName, unitType } } - const actorsByPhone = {} + const verifiedResidentsWithContactsByPhone = {} for (const contact of allVerifiedContactsOnUnit) { - const actor = { contact, resident: null, user: null } - actors.push(actor) - actorsByPhone[actor.contact.phone] = actor + const verifiedResidentWithContact = { contact, resident: null, user: null } + verifiedResidentsWithContacts.push(verifiedResidentWithContact) + verifiedResidentsWithContactsByPhone[verifiedResidentWithContact.contact.phone] = verifiedResidentWithContact } for (const resident of allResidentsOnUnit) { const phone = resident.user.phone - if (!phone || !actorsByPhone[phone]) continue - actorsByPhone[phone].resident = resident - actorsByPhone[phone].user = resident.user + if (!phone || !verifiedResidentsWithContactsByPhone[phone]) continue + verifiedResidentsWithContactsByPhone[phone].resident = resident + verifiedResidentsWithContactsByPhone[phone].user = resident.user } - actors = actors.filter(actor => !!actor.resident && !!actor.contact && !!actor.user) + verifiedResidentsWithContacts = verifiedResidentsWithContacts.filter(({ resident, user, contact }) => !!resident && !!contact && !!user) logContext.logInfoStats.step = 'find verified residents' - logContext.logInfoStats.contactsCount = actors.length + logContext.logInfoStats.contactsCount = verifiedResidentsWithContacts.length return { - actors, + verifiedResidentsWithContacts, earlyReturnValue: { verifiedContactsCount: allVerifiedContactsOnUnit.length, createdMessagesCount: 0, @@ -328,7 +327,7 @@ function parseSendMessageResults ({ logContext, sendMessagePromisesResults }) { logContext.logInfoStats.createMessageErrors.push(promiseResult.reason) return { error: promiseResult.reason } } - const { actor: { resident }, result } = promiseResult.value + const { resident, result } = promiseResult.value if (result.isDuplicateMessage) { logContext.logInfoStats.erroredMessagesCount++ @@ -363,7 +362,7 @@ function parseStartingMessagesIdsByUserIdsByMessageResults ({ sendMessagePromise continue } - const { actor: { user }, result } = promiseResult.value + const { user, result } = promiseResult.value if (result?.id) { startingMessagesIdsByUserIds[user.id] = result.id } @@ -513,8 +512,8 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer const { b2cAppId, b2cAppName } = await verifyApp({ context, logContext, addressKey, app }) // 2) Get verified residents - const { actors, earlyReturnValue } = await getActors({ context, logContext, addressKey, unitName, unitType }) - if (!actors.length) { + const { verifiedResidentsWithContacts, earlyReturnValue } = await getVerifiedResidentsWithContacts({ context, logContext, addressKey, unitName, unitType }) + if (!verifiedResidentsWithContacts.length) { return earlyReturnValue } const { verifiedContactsCount } = earlyReturnValue @@ -522,14 +521,14 @@ const SendVoIPStartMessageService = new GQLCustomSchema('SendVoIPStartMessageSer // 3) Check limits await checkLimits({ context, logContext, b2cAppId }) - const customVoIPValuesByContactId = await getCustomVoIPValuesByContacts({ context, contactIds: [...new Set(actors.map(actor => actor.contact.id))] }) + const customVoIPValuesByContactId = await getCustomVoIPValuesByContacts({ context, contactIds: [...new Set(verifiedResidentsWithContacts.map(({ contact }) => contact.id))] }) // 4) Send messages /** @type {Array>} */ - const sendMessagePromises = actors - .map((actor) => { - return sendMessageToActor({ - context, actor, customVoIPValuesByContactId, + const sendMessagePromises = verifiedResidentsWithContacts + .map(({ resident, contact, user }) => { + return sendMessageToUser({ + context, resident, contact, user, customVoIPValuesByContactId, dv, sender, callData, b2cApp: { id: b2cAppId, name: b2cAppName }, }) })