diff --git a/docs/docs/cmd/viva/engage/engage-community-remove.mdx b/docs/docs/cmd/viva/engage/engage-community-remove.mdx new file mode 100644 index 00000000000..e4f0aeb5b05 --- /dev/null +++ b/docs/docs/cmd/viva/engage/engage-community-remove.mdx @@ -0,0 +1,52 @@ +import Global from '/docs/cmd/_global.mdx'; + +# viva engage community remove + +Removes a Viva Engage community + +## Usage + +```sh +m365 viva engage community remove [options] +``` + +## Options + +```md definition-list +`-i, --id [id]` +: The id of the community. Specify either `id` or `displayName` but not both. + +`-n, --displayName [displayName]` +: The name of the community. Specify either `id` or `displayName` but not both. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Remarks + +:::info + +When the Viva Engage community is removed, all the associated Microsoft 365 content, including the M365 group, the document library, OneNote notebook, and Planner plans is deleted. + +::: + +## Examples + +Remove a community specified by id without prompting + +```sh +m365 viva engage community remove --id eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9 --force +``` + +Remove a community specified by name and prompt for confirmation + +```sh +m365 viva engage community remove --displayName 'Software Engineers' +``` + +## Response + +The command won't return a response on success \ No newline at end of file diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index e0b1e70e70e..7c407aa9601 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -4406,6 +4406,11 @@ const sidebars: SidebarsConfig = { label: 'engage community get', id: 'cmd/viva/engage/engage-community-get' }, + { + type: 'doc', + label: 'engage community remove', + id: 'cmd/viva/engage/engage-community-remove' + }, { type: 'doc', label: 'engage group list', diff --git a/src/m365/viva/commands.ts b/src/m365/viva/commands.ts index 6c8b6348937..ca688fa44f5 100644 --- a/src/m365/viva/commands.ts +++ b/src/m365/viva/commands.ts @@ -4,6 +4,7 @@ export default { CONNECTIONS_APP_CREATE: `${prefix} connections app create`, ENGAGE_COMMUNITY_ADD: `${prefix} engage community add`, ENGAGE_COMMUNITY_GET: `${prefix} engage community get`, + ENGAGE_COMMUNITY_REMOVE: `${prefix} engage community remove`, ENGAGE_GROUP_LIST: `${prefix} engage group list`, ENGAGE_GROUP_USER_ADD: `${prefix} engage group user add`, ENGAGE_GROUP_USER_REMOVE: `${prefix} engage group user remove`, diff --git a/src/m365/viva/commands/engage/Community.ts b/src/m365/viva/commands/engage/Community.ts new file mode 100644 index 00000000000..ecf30fbfb9c --- /dev/null +++ b/src/m365/viva/commands/engage/Community.ts @@ -0,0 +1,6 @@ +export interface Community { + id: string; + displayName: string; + description: string; + privacy: string; +} \ No newline at end of file diff --git a/src/m365/viva/commands/engage/engage-community-remove.spec.ts b/src/m365/viva/commands/engage/engage-community-remove.spec.ts new file mode 100644 index 00000000000..5968308b04a --- /dev/null +++ b/src/m365/viva/commands/engage/engage-community-remove.spec.ts @@ -0,0 +1,141 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import commands from '../../commands.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { cli } from '../../../../cli/cli.js'; +import command from './engage-community-remove.js'; +import { vivaEngage } from '../../../../utils/vivaEngage.js'; + +describe(commands.ENGAGE_COMMUNITY_REMOVE, () => { + const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'; + const displayName = 'Software Engineers'; + + let log: string[]; + let logger: Logger; + let promptIssued: boolean; + + 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; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + promptIssued = true; + return Promise.resolve(false); + }); + + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + vivaEngage.getCommunityIdByDisplayName, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ENGAGE_COMMUNITY_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('prompts before removing the community when confirm option not passed', async () => { + await command.action(logger, { options: { id: communityId } }); + + assert(promptIssued); + }); + + it('aborts removing the community when prompt not confirmed', async () => { + const deleteSpy = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: { id: communityId } }); + assert(deleteSpy.notCalled); + }); + + it('removes the community specified by id without prompting for confirmation', async () => { + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: communityId, force: true, verbose: true } }); + assert(deleteRequestStub.called); + }); + + it('removes the community specified by displayName while prompting for confirmation', async () => { + sinon.stub(vivaEngage, 'getCommunityIdByDisplayName').resolves(communityId); + + const deleteRequestStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { displayName: displayName } }); + assert(deleteRequestStub.called); + }); + + it('throws an error when the community specified by id cannot be found', async () => { + const error = { + error: { + code: 'notFound', + message: 'Not found.', + innerError: { + date: '2024-08-30T06:25:04', + 'request-id': '186480bb-73a7-4164-8a10-b05f45a94a4f', + 'client-request-id': '186480bb-73a7-4164-8a10-b05f45a94a4f' + } + } + }; + sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}`) { + throw error; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { id: communityId, force: true } }), + new CommandError(error.error.message)); + }); +}); \ No newline at end of file diff --git a/src/m365/viva/commands/engage/engage-community-remove.ts b/src/m365/viva/commands/engage/engage-community-remove.ts new file mode 100644 index 00000000000..88a4d0f6532 --- /dev/null +++ b/src/m365/viva/commands/engage/engage-community-remove.ts @@ -0,0 +1,113 @@ +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { cli } from '../../../../cli/cli.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { vivaEngage } from '../../../../utils/vivaEngage.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + id?: string; + displayName?: string; + force?: boolean +} + +class VivaEngageCommunityRemoveCommand extends GraphCommand { + public get name(): string { + return commands.ENGAGE_COMMUNITY_REMOVE; + } + public get description(): string { + return 'Removes a community'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + id: args.options.id !== 'undefined', + displayName: args.options.displayName !== 'undefined', + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --id [id]' + }, + { + option: '-n, --displayName [displayName]' + }, + { + option: '-f, --force' + } + ); + } + + #initOptionSets(): void { + this.optionSets.push( + { + options: ['id', 'displayName'] + } + ); + } + + #initTypes(): void { + this.types.string.push('id', 'displayName'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + + const removeCommunity = async (): Promise => { + try { + let communityId = args.options.id; + + if (args.options.displayName) { + communityId = await vivaEngage.getCommunityIdByDisplayName(args.options.displayName); + } + + if (args.options.verbose) { + await logger.logToStderr(`Removing Viva Engage community with ID ${communityId}...`); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/employeeExperience/communities/${communityId}`, + headers: { + accept: 'application/json;odata.metadata=none' + } + }; + + await request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await removeCommunity(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove Viva Engage community '${args.options.id || args.options.displayName}'?` }); + + if (result) { + await removeCommunity(); + } + } + } +} + +export default new VivaEngageCommunityRemoveCommand(); \ No newline at end of file diff --git a/src/utils/vivaEngage.spec.ts b/src/utils/vivaEngage.spec.ts new file mode 100644 index 00000000000..e372d61f405 --- /dev/null +++ b/src/utils/vivaEngage.spec.ts @@ -0,0 +1,108 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { cli } from '../cli/cli.js'; +import request from '../request.js'; +import { sinonUtil } from './sinonUtil.js'; +import { vivaEngage } from './vivaEngage.js'; +import { formatting } from './formatting.js'; +import { settingsNames } from '../settingsNames.js'; + +describe('utils/vivaEngage', () => { + const displayName = 'All Company'; + const invalidDisplayName = 'All Compayn'; + const communityResponse = { + "id": "eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9", + "description": "This is the default group for everyone in the network", + "displayName": "All Company", + "privacy": "Public" + }; + const anotherCommunityResponse = { + "id": "eyJfdHlwZ0NzY5SIwiIiSJ9IwO6IaWQiOIMTM1ODikdyb3Vw", + "description": "Test only", + "displayName": "All Company", + "privacy": "Private" + }; + + afterEach(() => { + sinonUtil.restore([ + request.get, + cli.getSettingWithDefaultValue, + cli.handleMultipleResultsFound + ]); + }); + + it('correctly get single community id by name using getCommunityIdByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + const actual = await vivaEngage.getCommunityIdByDisplayName(displayName); + assert.deepStrictEqual(actual, 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'); + }); + + it('handles selecting single community when multiple communities with the specified name found using getCommunityIdByDisplayName and cli is set to prompt', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse, + anotherCommunityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves(communityResponse); + + const actual = await vivaEngage.getCommunityIdByDisplayName(displayName); + assert.deepStrictEqual(actual, 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'); + }); + + it('throws error message when no community was found using getCommunityIdByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(invalidDisplayName)}'`) { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getCommunityIdByDisplayName(invalidDisplayName)), Error(`The specified Viva Engage community '${invalidDisplayName}' does not exist.`); + }); + + it('throws error message when multiple communities were found using getCommunityIdByDisplayName', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse, + anotherCommunityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getCommunityIdByDisplayName(displayName), + Error(`Multiple Viva Engage communities with name '${displayName}' found. Found: ${communityResponse.id}, ${anotherCommunityResponse.id}.`)); + }); +}); \ No newline at end of file diff --git a/src/utils/vivaEngage.ts b/src/utils/vivaEngage.ts new file mode 100644 index 00000000000..35bc9461529 --- /dev/null +++ b/src/utils/vivaEngage.ts @@ -0,0 +1,28 @@ +import { cli } from '../cli/cli.js'; +import { Community } from '../m365/viva/commands/engage/Community.js'; +import { formatting } from './formatting.js'; +import { odata } from './odata.js'; + +export const vivaEngage = { + /** + * Get Viva Engage community ID by display name. + * @param displayName Community display name. + * @returns The ID of the Viva Engage community. + * @throws Error when the community was not found. + */ + async getCommunityIdByDisplayName(displayName: string): Promise { + const communities = await odata.getAllItems(`https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`); + + if (communities.length === 0) { + throw `The specified Viva Engage community '${displayName}' does not exist.`; + } + + if (communities.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', communities); + const selectedCommunity = await cli.handleMultipleResultsFound(`Multiple Viva Engage communities with name '${displayName}' found.`, resultAsKeyValuePair); + return selectedCommunity.id; + } + + return communities[0].id; + } +}; \ No newline at end of file