From 76c9ecd106941f451460819806eb760b83be0647 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 11 Apr 2024 14:55:54 +0200 Subject: [PATCH] Adds command 'viva engage community add'. Closes #5753 --- .../cmd/viva/engage/engage-community-add.mdx | 168 +++++++ docs/src/config/sidebars.ts | 5 + src/m365/viva/commands.ts | 1 + .../engage/engage-community-add.spec.ts | 416 ++++++++++++++++++ .../commands/engage/engage-community-add.ts | 223 ++++++++++ 5 files changed, 813 insertions(+) create mode 100644 docs/docs/cmd/viva/engage/engage-community-add.mdx create mode 100644 src/m365/viva/commands/engage/engage-community-add.spec.ts create mode 100644 src/m365/viva/commands/engage/engage-community-add.ts diff --git a/docs/docs/cmd/viva/engage/engage-community-add.mdx b/docs/docs/cmd/viva/engage/engage-community-add.mdx new file mode 100644 index 00000000000..63a274408ea --- /dev/null +++ b/docs/docs/cmd/viva/engage/engage-community-add.mdx @@ -0,0 +1,168 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# viva engage community add + +Creates a new community in Viva Engage. + +## Usage + +```sh +m365 viva engage community add [options] +``` + +## Options + +```md definition-list +`--displayName ` +: The name of the community. The maximum length is 255 characters. + +`--description ` +: The description of the community. The maximum length is 1024 characters. + +`--privacy ` +: Defines the privacy level of the community. The possible values are: `public`, `private`. + +`--adminEntraIds [adminEntraIds]` +: Comma-separated list of Microsoft Entra IDs, assigning admin privileges to the designated individuals. Required when using application permissions. Specify either `adminEntraIds` or `adminEntraUserNames`, but not both. + +`--adminEntraUserNames [adminEntraUserNames]` +: Comma-separated list of Microsoft Entra UPNs, assigning admin privileges to the designated individuals. Required when using application permissions. Specify either `adminEntraIds` or `adminEntraUserNames`, but not both. + +`--wait` +: Wait for the operation to finish. +``` + + + +## Remarks + +:::warning + +This command is based on an API that is currently in preview and is subject to change once the API reaches general availability. + +::: + +Creating a community is limited to networks in Native mode only - legacy and external Yammer networks will not be able to use this API for community creation. + +## Examples + +Create a public community and wait for the community to be created + +```sh +m365 viva engage community add --displayName "Software engineers" --description "A community for all software engineers" --privacy public --wait +``` + +Create a private community + +```sh +m365 viva engage community add --displayName "Software engineers" --description "A community for all software engineers" --privacy private +``` + +Create a private community with owners + +```sh +m365 viva engage community add --displayName "Software engineers" --description "A community for all software engineers" --privacy private --adminEntraUserNames "john.doe@contoso.onmicrosoft.com,jane.doe@contoso.onmicrosoft.com" +``` + +## Response + +### Standard response + + + + + ```json + "https://graph.microsoft.com/beta/employeeExperience/engagementAsyncOperations('eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI4ZmM2NzEyZS0wMWY4LTQxN2YtYWNmMS1iZTJiYmMxY2FjNGQiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ')" + ``` + + + + + ```text + https://graph.microsoft.com/beta/employeeExperience/engagementAsyncOperations('eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI4ZmM2NzEyZS0wMWY4LTQxN2YtYWNmMS1iZTJiYmMxY2FjNGQiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ') + ``` + + + + + ```csv + https://graph.microsoft.com/beta/employeeExperience/engagementAsyncOperations('eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI4ZmM2NzEyZS0wMWY4LTQxN2YtYWNmMS1iZTJiYmMxY2FjNGQiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ') + ``` + + + + + ```md + https://graph.microsoft.com/beta/employeeExperience/engagementAsyncOperations('eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI4ZmM2NzEyZS0wMWY4LTQxN2YtYWNmMS1iZTJiYmMxY2FjNGQiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ') + ``` + + + + +### `wait` response + +When we make use of the option `wait` the response will differ. + + + + + ```json + { + "id": "eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI2ODgxMGQ4ZS1lODk2LTRhNzEtYWM5NC02NGIyZThlMmMyOGUiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ", + "createdDateTime": "2024-04-10T12:16:18.0240578Z", + "lastActionDateTime": "2024-04-10T12:16:18.0240592Z", + "status": "succeeded", + "statusDetail": null, + "resourceLocation": "https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODQ4MTMxNTAyMDgifQ')", + "operationType": "createCommunity", + "resourceId": "eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODQ4MTMxNTAyMDgifQ" + } + ``` + + + + + ```text + createdDateTime : 2024-04-10T12:45:03.4020639Z + id : eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI5NzRkMjUzZS1jMGUzLTQ2YjgtYTkzMy0zZDZhODU1NGE0OWUiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ + lastActionDateTime: 2024-04-10T12:45:03.4020656Z + operationType : createCommunity + resourceId : eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODQ4MTUwNTA3NTIifQ + resourceLocation : https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODQ4MTUwNTA3NTIifQ') + status : succeeded + statusDetail : null + ``` + + + + + ```csv + id,createdDateTime,lastActionDateTime,status,statusDetail,resourceLocation,operationType,resourceId + eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiJiMDU0MjFmNC0zNzU5LTQ3NzgtOTVhYi01NzhiYjAzZGNiMWQiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ,2024-04-12T11:24:29.074105Z,2024-04-12T11:24:29.0741064Z,succeeded,,https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODUwMTc2NzE2ODAifQ'),createCommunity,eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODUwMTc2NzE2ODAifQ + ``` + + + + + ```md + # viva engage community add --displayName "Software engineers" --description "A community for all software engineers" --privacy "private" --wait "true" + + Date: 10/04/2024 + + ## eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI5ZTgzMDRjMC1mZmY0LTRkODgtYTM4OS1mNDY0MmQ3OGZlMTciLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ + + Property | Value + ---------|------- + id | eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI5ZTgzMDRjMC1mZmY0LTRkODgtYTM4OS1mNDY0MmQ3OGZlMTciLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ + createdDateTime | 2024-04-10T12:47:47.3488087Z + lastActionDateTime | 2024-04-10T12:47:47.3488107Z + status | succeeded + resourceLocation | https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODQ4MTUyMzkxNjgifQ') + operationType | createCommunity + resourceId | eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxODQ4MTUyMzkxNjgifQ + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index f0bde194e95..53a4f416d31 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -4346,6 +4346,11 @@ const sidebars: SidebarsConfig = { label: 'engage search', id: 'cmd/viva/engage/engage-search' }, + { + type: 'doc', + label: 'engage community add', + id: 'cmd/viva/engage/engage-community-add' + }, { type: 'doc', label: 'engage community get', diff --git a/src/m365/viva/commands.ts b/src/m365/viva/commands.ts index 7b5f91495c5..6c8b6348937 100644 --- a/src/m365/viva/commands.ts +++ b/src/m365/viva/commands.ts @@ -2,6 +2,7 @@ const prefix: string = 'viva'; export default { CONNECTIONS_APP_CREATE: `${prefix} connections app create`, + ENGAGE_COMMUNITY_ADD: `${prefix} engage community add`, ENGAGE_COMMUNITY_GET: `${prefix} engage community get`, ENGAGE_GROUP_LIST: `${prefix} engage group list`, ENGAGE_GROUP_USER_ADD: `${prefix} engage group user add`, diff --git a/src/m365/viva/commands/engage/engage-community-add.spec.ts b/src/m365/viva/commands/engage/engage-community-add.spec.ts new file mode 100644 index 00000000000..10406877c2f --- /dev/null +++ b/src/m365/viva/commands/engage/engage-community-add.spec.ts @@ -0,0 +1,416 @@ + +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './engage-community-add.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { settingsNames } from '../../../../settingsNames.js'; +import { entraUser } from '../../../../utils/entraUser.js'; + +describe(commands.ENGAGE_COMMUNITY_ADD, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let loggerLogSpy: sinon.SinonSpy; + const operationLocation = `https://graph.microsoft.com/beta/employeeExperience/engagementAsyncOperations('eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiI4ZmM2NzEyZS0wMWY4LTQxN2YtYWNmMS1iZTJiYmMxY2FjNGQiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ')`; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + if (!auth.connection.accessTokens[auth.defaultResource]) { + auth.connection.accessTokens[auth.defaultResource] = { + expiresOn: 'abc', + accessToken: 'abc' + }; + } + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + (command as any).pollingInterval = 0; + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.post, + accessToken.isAppOnlyAccessToken, + entraUser.getUserIdsByUpns + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ENGAGE_COMMUNITY_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if \'displayName\' is more than 255 characters', async () => { + const actual = await command.validate({ + options: { + displayName: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries.", + description: "A community for all software engineers", + privacy: 'public' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if \'description\' is more than 1024 characters', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable. If you are going to use a passage of Lorem Ipsum, you need to be sure there isn't anything embarrassing hidden in the middle of text.All the Lorem Ipsum generators on the Internet tend to repeat predefined chunks as necessary, making this the first true generator on the Internet.`, + privacy: 'public' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when invalid privacy option is provided', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'invalid' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when invalid adminEntraId is provided', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'private', + adminEntraIds: 'invalid' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when invalid adminEntraUserName is provided', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'private', + adminEntraUserNames: 'invalid' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both adminEntraIds and adminEntraUserNames are specified', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers.', + privacy: 'public', + adminEntraIds: '50674d84-6bf1-470b-89b5-d55ce0a5a720', + adminEntraUserNames: 'john.doe@contoso.onmicrosoft.com' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when more than 20 admins are specified by id', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers.', + privacy: 'public', + adminEntraIds: Array(21).fill('50674d84-6bf1-470b-89b5-d55ce0a5a720').join(',') + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when more than 20 admins are specified by UPN', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers.', + privacy: 'public', + adminEntraUserNames: Array(21).fill('john.doe@contoso.onmicrosoft.com').join(',') + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when valid options are provided with adminEntraIds', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers.', + privacy: 'public', + adminEntraIds: '50674d84-6bf1-470b-89b5-d55ce0a5a720' + } + }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when valid options are provided with adminEntraUserNames', async () => { + const actual = await command.validate({ + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers.', + privacy: 'public', + adminEntraUserNames: 'john.doe@contoso.onmicrosoft.com' + } + }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('creates a community without waiting for provisioning to complete', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/employeeExperience/communities`) { + return { + headers: { + location: operationLocation + } + }; + } + throw 'Invalid request'; + }); + await command.action(logger, { options: { displayName: 'Software engineers', description: 'A community for all software engineers', privacy: 'public', verbose: true } }); + assert(loggerLogSpy.calledOnceWithExactly(operationLocation)); + }); + + it('creates a community with adminEntraIds and waits for provisioning to complete', async () => { + let i = 0; + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/employeeExperience/communities`) { + return { + headers: { + location: operationLocation + } + }; + } + throw 'Invalid request'; + }); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === operationLocation) { + if (i++ < 2) { + return { + status: 'running' + }; + } + + return { + id: 'eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiJmYzg3MzBlZS0wN2Q4LTQ1OGMtYjIzOC1mMmRmNTlmMzhkNmIiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ', + createdDateTime: '2024-07-20T21:30:32.2441923Z', + lastActionDateTime: '2024-07-20T21:30:32.2441938Z', + status: 'succeeded', + statusDetail: null, + resourceLocation: `https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ')`, + operationType: 'createCommunity', + resourceId: 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ' + }; + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'public', + adminEntraIds: '50674d84-6bf1-470b-89b5-d55ce0a5a720', + wait: true, + verbose: true + } + }); + + assert(loggerLogSpy.calledOnceWithExactly({ + id: 'eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiJmYzg3MzBlZS0wN2Q4LTQ1OGMtYjIzOC1mMmRmNTlmMzhkNmIiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ', + createdDateTime: '2024-07-20T21:30:32.2441923Z', + lastActionDateTime: '2024-07-20T21:30:32.2441938Z', + status: 'succeeded', + statusDetail: null, + resourceLocation: `https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ')`, + operationType: 'createCommunity', + resourceId: 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ' + })); + }); + + it('creates a community with adminEntraUserNames and waits for provisioning to complete', async () => { + sinon.stub(entraUser, 'getUserIdsByUpns').withArgs(['john.doe@consoto.onmicrosoft.com']).resolves(['50674d84-6bf1-470b-89b5-d55ce0a5a720']); + let i = 0; + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/employeeExperience/communities`) { + return { + headers: { + location: operationLocation + } + }; + } + throw 'Invalid request'; + }); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === operationLocation) { + if (i++ < 2) { + return { + status: 'running' + }; + } + + return { + id: 'eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiJmYzg3MzBlZS0wN2Q4LTQ1OGMtYjIzOC1mMmRmNTlmMzhkNmIiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ', + createdDateTime: '2024-07-20T21:30:32.2441923Z', + lastActionDateTime: '2024-07-20T21:30:32.2441938Z', + status: 'succeeded', + statusDetail: null, + resourceLocation: `https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ')`, + operationType: 'createCommunity', + resourceId: 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ' + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'public', + adminEntraUserNames: 'john.doe@consoto.onmicrosoft.com', + wait: true, + debug: true + } + }); + + assert(loggerLogSpy.calledOnceWithExactly({ + id: 'eyJfdHlwZSI6IkxvbmdSdW5uaW5nT3BlcmF0aW9uIiwiaWQiOiJmYzg3MzBlZS0wN2Q4LTQ1OGMtYjIzOC1mMmRmNTlmMzhkNmIiLCJvcGVyYXRpb24iOiJDcmVhdGVDb21tdW5pdHkifQ', + createdDateTime: '2024-07-20T21:30:32.2441923Z', + lastActionDateTime: '2024-07-20T21:30:32.2441938Z', + status: 'succeeded', + statusDetail: null, + resourceLocation: `https://graph.microsoft.com/beta/employeeExperience/communities('eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ')`, + operationType: 'createCommunity', + resourceId: 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIxOTcxODQ5NzA3NTIifQ' + })); + }); + + it('handles error when waiting for provisioning to complete fails', async () => { + let i = 0; + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/beta/employeeExperience/communities`) { + return { + headers: { + location: operationLocation + } + }; + } + throw 'Invalid request'; + }); + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === operationLocation) { + if (i++ < 2) { + return { + status: 'running' + }; + } + + return { + status: 'failed', + statusDetail: 'An error has occurred' + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'public', + wait: true + } + }), new CommandError('Community creation failed: An error has occurred')); + }); + + it('handles error when at least admin is not provided while using app-only authentication', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + await assert.rejects(command.action(logger, { + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'public', + wait: true + } + }), new CommandError('Specify at least one admin using either adminEntraIds or adminEntraUserNames options when using application permissions.')); + }); + + it('handles API error', async () => { + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'Invalid request' + } + } + } + }); + + await assert.rejects(command.action(logger, { + options: { + displayName: 'Software engineers', + description: 'A community for all software engineers', + privacy: 'public', + wait: true + } + }), new CommandError('Invalid request')); + }); +}); \ No newline at end of file diff --git a/src/m365/viva/commands/engage/engage-community-add.ts b/src/m365/viva/commands/engage/engage-community-add.ts new file mode 100644 index 00000000000..81ff01b6461 --- /dev/null +++ b/src/m365/viva/commands/engage/engage-community-add.ts @@ -0,0 +1,223 @@ +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { validation } from '../../../../utils/validation.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import auth from '../../../../Auth.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { setTimeout } from 'timers/promises'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + displayName: string; + description: string; + privacy: string; + adminEntraIds?: string; + adminEntraUserNames?: string; + wait?: boolean; +} + +class VivaEngageCommunityAddCommand extends GraphCommand { + private pollingInterval: number = 5000; + private readonly privacyOptions = ['public', 'private']; + + public get name(): string { + return commands.ENGAGE_COMMUNITY_ADD; + } + + public get description(): string { + return 'Creates a new community in Viva Engage'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initTypes(); + this.#initOptionSets(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + adminEntraIds: typeof args.options.adminEntraIds !== 'undefined', + adminEntraUserNames: typeof args.options.adminEntraUserNames !== 'undefined', + wait: !!args.options.wait + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { option: '--displayName ' }, + { option: '--description ' }, + { + option: '--privacy ', + autocomplete: this.privacyOptions + }, + { option: '--adminEntraIds [adminEntraIds]' }, + { option: '--adminEntraUserNames [adminEntraUserNames]' }, + { option: '--wait' } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.displayName.length > 255) { + return `The maximum amount of characters for 'displayName' is 255.`; + } + + if (args.options.description.length > 1024) { + return `The maximum amount of characters for 'description' is 1024.`; + } + + if (this.privacyOptions.indexOf(args.options.privacy) === -1) { + return `'${args.options.privacy}' is not a valid value for privacy. Allowed values are: ${this.privacyOptions.join(', ')}.`; + } + + if (args.options.adminEntraIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.adminEntraIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for the option 'adminEntraIds': ${isValidGUIDArrayResult}.`; + } + if (formatting.splitAndTrim(args.options.adminEntraIds).length > 20) { + return `Maximum of 20 admins allowed. Please reduce the number of users and try again.`; + } + } + + if (args.options.adminEntraUserNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.adminEntraUserNames); + if (isValidUPNArrayResult !== true) { + return `The following user principal names are invalid for the option 'adminEntraUserNames': ${isValidUPNArrayResult}.`; + } + if (formatting.splitAndTrim(args.options.adminEntraUserNames).length > 20) { + return `Maximum of 20 admins allowed. Please reduce the number of users and try again.`; + } + } + + return true; + } + ); + } + + #initTypes(): void { + this.types.string.push('displayName', 'description', 'privacy', 'adminEntraIds', 'adminEntraUserNames'); + this.types.boolean.push('wait'); + } + + #initOptionSets(): void { + this.optionSets.push( + { + options: ['adminEntraIds', 'adminEntraUserNames'], + runsWhen: (args) => args.options.adminEntraIds || args.options.adminEntraUserNames + } + ); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const { displayName, description, privacy, adminEntraIds, adminEntraUserNames, wait } = args.options; + + const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken); + if (isAppOnlyAccessToken && !adminEntraIds && !adminEntraUserNames) { + this.handleError(`Specify at least one admin using either adminEntraIds or adminEntraUserNames options when using application permissions.`); + } + + if (this.verbose) { + await logger.logToStderr(`Creating a Viva Engage community with display name '${displayName}'...`); + } + + try { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/beta/employeeExperience/communities`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json' + }, + responseType: 'json', + fullResponse: true, + data: { + displayName: displayName, + description: description, + privacy: privacy + } + }; + + const entraIds = await this.getGraphUserUrls(args.options); + if (entraIds.length > 0) { + requestOptions.data['owners@odata.bind'] = entraIds; + } + + const res = await request.post<{ headers: { location: string } }>(requestOptions); + + const location = res.headers.location; + + if (!wait) { + await logger.log(location); + return; + } + + let status: string; + do { + if (this.verbose) { + await logger.logToStderr(`Community still provisioning. Retrying in ${this.pollingInterval / 1000} seconds...`); + } + + await setTimeout(this.pollingInterval); + + if (this.verbose) { + await logger.logToStderr(`Checking create community operation status...`); + } + + const operation = await request.get({ + url: location, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }); + status = operation.status; + + if (this.verbose) { + await logger.logToStderr(`Community creation operation status: ${status}`); + } + + if (status === 'failed') { + throw `Community creation failed: ${operation.statusDetail}`; + } + + if (status === 'succeeded') { + await logger.log(operation); + } + } + while (status === 'notStarted' || status === 'running'); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getGraphUserUrls(options: Options): Promise { + let entraIds: string[] = []; + + if (options.adminEntraIds) { + entraIds = formatting.splitAndTrim(options.adminEntraIds); + } + else if (options.adminEntraUserNames) { + entraIds = await entraUser.getUserIdsByUpns(formatting.splitAndTrim(options.adminEntraUserNames)); + } + + const graphUserUrls = entraIds.map(id => `${this.resource}/beta/users/${id}`); + return graphUserUrls; + } +} + +export default new VivaEngageCommunityAddCommand(); \ No newline at end of file