From 89971496a77f5455e86c85881cb15e721b456e50 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Thu, 26 Sep 2024 19:22:20 +0200 Subject: [PATCH] Adds command `viva engage community user remove`. Closes #6296 --- .../engage/engage-community-user-remove.mdx | 55 ++++ docs/src/config/sidebars.ts | 5 + src/m365/viva/commands.ts | 1 + src/m365/viva/commands/engage/Community.ts | 1 + .../engage-community-user-remove.spec.ts | 239 ++++++++++++++++++ .../engage/engage-community-user-remove.ts | 125 +++++++++ src/utils/vivaEngage.spec.ts | 187 ++++++++++++++ src/utils/vivaEngage.ts | 79 ++++++ 8 files changed, 692 insertions(+) create mode 100644 docs/docs/cmd/viva/engage/engage-community-user-remove.mdx create mode 100644 src/m365/viva/commands/engage/engage-community-user-remove.spec.ts create mode 100644 src/m365/viva/commands/engage/engage-community-user-remove.ts create mode 100644 src/utils/vivaEngage.spec.ts create mode 100644 src/utils/vivaEngage.ts diff --git a/docs/docs/cmd/viva/engage/engage-community-user-remove.mdx b/docs/docs/cmd/viva/engage/engage-community-user-remove.mdx new file mode 100644 index 0000000000..9d95a95fc8 --- /dev/null +++ b/docs/docs/cmd/viva/engage/engage-community-user-remove.mdx @@ -0,0 +1,55 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# viva engage community user remove + +Removes a specified user from a Microsoft 365 Viva Engage community + +## Usage + +```sh +m365 viva engage community user remove [options] +``` + +## Options + +```md definition-list +`-i, --communityId [communityId]` +: The ID of the Viva Engage community. Specify `communityId`, `communityDisplayName` or `entraGroupId`. + +`-n, --communityDisplayName [communityDisplayName]` +: The display name of the Viva Engage community. Specify `communityId`, `communityDisplayName` or `entraGroupId`. + +`--entraGroupId [entraGroupId]` +: The ID of the Microsoft 365 group. Specify `communityId`, `communityDisplayName` or `entraGroupId`. + +`--id [id]` +: Microsoft Entra ID of the user. Specify either `id` or `userName` but not both. + +`--userName [userName]` +: The user principal name of the user. Specify either `id` or `userName` but not both. + +`-f, --force` +: Don't prompt for confirming removing the user from the specified Viva Engage community. +``` + + + +## Examples + +Remove a user specified by ID as a member from a community specified by display name. + +```sh +m365 viva engage community user remove --communityDisplayName "All company" --id 098b9f52-f48c-4401-819f-29c33794c3f5 +``` + +Remove a user specified by UPN from a community specified by its group ID without confirmation. + +```sh +m365 viva engage community user remove --entraGroupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userName john.doe@contoso.com --force +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 929281df70..5c6072a3b1 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -4475,6 +4475,11 @@ const sidebars: SidebarsConfig = { label: 'engage community list', id: 'cmd/viva/engage/engage-community-list' }, + { + type: 'doc', + label: 'engage community user remove', + id: 'cmd/viva/engage/engage-community-user-remove' + }, { type: 'doc', label: 'engage group list', diff --git a/src/m365/viva/commands.ts b/src/m365/viva/commands.ts index f273274983..96f5dafb1f 100644 --- a/src/m365/viva/commands.ts +++ b/src/m365/viva/commands.ts @@ -5,6 +5,7 @@ export default { ENGAGE_COMMUNITY_ADD: `${prefix} engage community add`, ENGAGE_COMMUNITY_GET: `${prefix} engage community get`, ENGAGE_COMMUNITY_LIST: `${prefix} engage community list`, + ENGAGE_COMMUNITY_USER_REMOVE: `${prefix} engage community user 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 index ed5df59649..75f5405227 100644 --- a/src/m365/viva/commands/engage/Community.ts +++ b/src/m365/viva/commands/engage/Community.ts @@ -3,4 +3,5 @@ export interface Community { displayName: string; description?: string; privacy: string; + groupId: string; } \ No newline at end of file diff --git a/src/m365/viva/commands/engage/engage-community-user-remove.spec.ts b/src/m365/viva/commands/engage/engage-community-user-remove.spec.ts new file mode 100644 index 0000000000..2db76a5762 --- /dev/null +++ b/src/m365/viva/commands/engage/engage-community-user-remove.spec.ts @@ -0,0 +1,239 @@ + +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 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-user-remove.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { z } from 'zod'; +import { cli } from '../../../../cli/cli.js'; +import { vivaEngage } from '../../../../utils/vivaEngage.js'; +import { entraUser } from '../../../../utils/entraUser.js'; + +describe(commands.ENGAGE_COMMUNITY_USER_REMOVE, () => { + const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIzNjAyMDAxMTAwOSJ9'; + const communityDisplayName = 'All company'; + const entraGroupId = 'b6c35b51-ebca-445c-885a-63a67d24cb53'; + const userName = 'john@contoso.com'; + const userId = '3f2504e0-4f89-11d3-9a0c-0305e82c3301'; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + 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; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + sinon.stub(entraUser, 'getUserIdByUpn').resolves(userId); + sinon.stub(vivaEngage, 'getEntraGroupIdByCommunityDisplayName').resolves(entraGroupId); + sinon.stub(vivaEngage, 'getEntraGroupIdByCommunityId').resolves(entraGroupId); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ENGAGE_COMMUNITY_USER_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if entraGroupId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: 'invalid', + userName: userName + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: entraGroupId, + id: 'invalid' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is invalid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: entraGroupId, + userName: 'invalid' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if communityId, communityDisplayName or entraGroupId are not specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if communityId, communityDisplayName and entraGroupId are specified', () => { + const actual = commandOptionsSchema.safeParse({ + communityId: communityId, + communityDisplayName: communityDisplayName, + entraGroupId: entraGroupId, + id: userId + }); + assert.notStrictEqual(actual.success, true); + }); + + it('passes validation if communityId is specified', () => { + const actual = commandOptionsSchema.safeParse({ + communityId: communityId, + userName: userName + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if entraGroupId is specified with a proper GUID', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: entraGroupId, + userName: userName + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if communityDisplayName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + communityDisplayName: communityDisplayName, + userName: userName + }); + assert.strictEqual(actual.success, true); + }); + + it('correctly removes user specified by id', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + return; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/members/${userId}/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { communityDisplayName: communityDisplayName, id: userId, force: true, verbose: true } }); + assert(deleteStub.calledTwice); + }); + + it('correctly removes user by userName', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + return; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/members/${userId}/$ref`) { + return; + } + throw 'Invalid request'; + }); + + await command.action(logger, { options: { communityId: communityId, verbose: true, userName: userName, force: true } }); + assert(deleteStub.calledTwice); + }); + + it('correctly removes user as member by userName', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + throw { + response: { + status: 404, + data: { + message: 'Object does not exist...' + } + } + }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/members/${userId}/$ref`) { + return; + } + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { communityId: communityId, verbose: true, userName: userName } }); + assert(deleteStub.calledTwice); + }); + + it('handles API error when removing user', async () => { + const errorMessage = 'Invalid object identifier'; + sinon.stub(request, 'delete').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + throw { + response: { + status: 400, + data: { error: { 'odata.error': { message: { value: errorMessage } } } } + } + }; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await assert.rejects(command.action(logger, { options: { entraGroupId: entraGroupId, id: userId } }), + new CommandError(errorMessage)); + }); + + it('prompts before removal when confirmation argument not passed', async () => { + const promptStub: sinon.SinonStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { options: { entraGroupId: entraGroupId, id: userId } }); + + assert(promptStub.called); + }); + + it('aborts execution when prompt not confirmed', async () => { + const deleteStub = sinon.stub(request, 'delete'); + sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { options: { entraGroupId: entraGroupId, id: userId } }); + assert(deleteStub.notCalled); + }); +}); \ No newline at end of file diff --git a/src/m365/viva/commands/engage/engage-community-user-remove.ts b/src/m365/viva/commands/engage/engage-community-user-remove.ts new file mode 100644 index 0000000000..a0de421306 --- /dev/null +++ b/src/m365/viva/commands/engage/engage-community-user-remove.ts @@ -0,0 +1,125 @@ +import { z } from 'zod'; +import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { zod } from '../../../../utils/zod.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import { vivaEngage } from '../../../../utils/vivaEngage.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { cli } from '../../../../cli/cli.js'; + +const options = globalOptionsZod + .extend({ + communityId: z.string().optional(), + communityDisplayName: zod.alias('n', z.string().optional()), + entraGroupId: z.string() + .refine(id => validation.isValidGuid(id), id => ({ + message: `'${id}' is not a valid GUID.` + })).optional(), + id: z.string() + .refine(id => validation.isValidGuid(id), id => ({ + message: `'${id}' is not a valid GUID.` + })).optional(), + userName: z.string() + .refine(userName => validation.isValidUserPrincipalName(userName), userName => ({ + message: `'${userName}' is not a valid user principal name.` + })).optional(), + force: z.boolean().optional() + }) + .strict(); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class VivaEngageCommunityUserRemoveCommand extends GraphCommand { + + public get name(): string { + return commands.ENGAGE_COMMUNITY_USER_REMOVE; + } + + public get description(): string { + return 'Removes a specified user from a Microsoft 365 Viva Engage community'; + } + + public get schema(): z.ZodTypeAny { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => [options.communityId, options.communityDisplayName, options.entraGroupId].filter(x => x !== undefined).length === 1, { + message: 'Specify either communityId, communityDisplayName, or entraGroupId, but not multiple.' + }) + .refine(options => options.communityId || options.communityDisplayName || options.entraGroupId, { + message: 'Specify at least one of communityId, communityDisplayName, or entraGroupId.' + }) + .refine(options => options.id || options.userName, { + message: 'Specify either of id or userName.' + }) + .refine(options => typeof options.userName !== undefined && typeof options.id !== undefined, { + message: 'Specify either id or userName, but not both.' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (args.options.force) { + await this.deleteUserFromCommunity(args.options, logger); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove the user ${args.options.id || args.options.userName} from the community ${args.options.communityDisplayName || args.options.communityId || args.options.entraGroupId}?` }); + + if (result) { + await this.deleteUserFromCommunity(args.options, logger); + } + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async deleteUserFromCommunity(options: Options, logger: Logger): Promise { + if (this.verbose) { + await logger.logToStderr('Removing user from community...'); + } + + let entraGroupId = options.entraGroupId; + + if (options.communityDisplayName) { + entraGroupId = await vivaEngage.getEntraGroupIdByCommunityDisplayName(options.communityDisplayName); + } + else if (options.communityId) { + entraGroupId = await vivaEngage.getEntraGroupIdByCommunityId(options.communityId); + } + + const userId = options.id || await entraUser.getUserIdByUpn(options.userName!); + + await this.deleteUser(entraGroupId!, userId, 'owners'); + await this.deleteUser(entraGroupId!, userId, 'members'); + } + + private async deleteUser(entraGroupId: string, userId: string, role: string): Promise { + try { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/groups/${entraGroupId}/${role}/${userId}/$ref`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + await request.delete(requestOptions); + } + catch (err: any) { + if (err.response.status !== 404) { + throw err.response.data; + } + } + } +} + +export default new VivaEngageCommunityUserRemoveCommand(); \ 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 0000000000..baf42f2aed --- /dev/null +++ b/src/utils/vivaEngage.spec.ts @@ -0,0 +1,187 @@ +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 entraGroupId = '0bed8b86-5026-4a93-ac7d-56750cc099f1'; + const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'; + const communityResponse = { + "id": "eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9", + "description": "This is the default group for everyone in the network", + "displayName": "All Company", + "privacy": "Public", + "groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1" + }; + const anotherCommunityResponse = { + "id": "eyJfdHlwZ0NzY5SIwiIiSJ9IwO6IaWQiOIMTM1ODikdyb3Vw", + "description": "Test only", + "displayName": "All Company", + "privacy": "Private", + "groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1" + }; + + 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}.`)); + }); + + it('correctly get single community id by group id using getCommunityIdByEntraGroupId', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId') { + return { + value: [ + communityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + const actual = await vivaEngage.getCommunityIdByEntraGroupId(entraGroupId); + assert.deepStrictEqual(actual, 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'); + }); + + it('throws error message when no community was found using getCommunityIdByEntraGroupId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId') { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getCommunityIdByEntraGroupId(entraGroupId)), Error(`The Microsoft Entra group with id '${entraGroupId}' is not associated with any Viva Engage community.`); + }); + + it('correctly gets Entra group ID by community ID using getEntraGroupIdByCommunityId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`) { + return communityResponse; + } + + throw 'Invalid Request'; + }); + + const actual = await vivaEngage.getEntraGroupIdByCommunityId(communityId); + assert.deepStrictEqual(actual, '0bed8b86-5026-4a93-ac7d-56750cc099f1'); + }); + + it('throws error message when no Entra group ID was found using getEntraGroupIdByCommunityId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`) { + return null; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getEntraGroupIdByCommunityId(communityId)), Error(`The specified Viva Engage community with ID '${communityId}' does not exist.`); + }); + + it('correctly gets Entra group ID by community display name using getEntraGroupIdByCommunityDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`) { + return communityResponse; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + const actual = await vivaEngage.getEntraGroupIdByCommunityDisplayName(displayName); + assert.deepStrictEqual(actual, entraGroupId); + }); +}); \ No newline at end of file diff --git a/src/utils/vivaEngage.ts b/src/utils/vivaEngage.ts new file mode 100644 index 0000000000..9fc4403a01 --- /dev/null +++ b/src/utils/vivaEngage.ts @@ -0,0 +1,79 @@ +import { cli } from '../cli/cli.js'; +import { Community } from '../m365/viva/commands/engage/Community.js'; +import request, { CliRequestOptions } from '../request.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. + */ + 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; + }, + + /** + * Get Viva Engage community ID by Microsoft Entra group ID. + * Note: The Graph API doesn't support filtering by groupId, so we need to retrieve all communities and filter them in memory. + * @param entraGroupId The ID of the Microsoft Entra group. + * @returns The ID of the Viva Engage community. + */ + async getCommunityIdByEntraGroupId(entraGroupId: string): Promise { + const communities = await odata.getAllItems('https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId'); + + const filtereCommunities = communities.filter(c => c.groupId === entraGroupId); + + if (filtereCommunities.length === 0) { + throw `The Microsoft Entra group with id '${entraGroupId}' is not associated with any Viva Engage community.`; + } + + return filtereCommunities[0].id; + }, + + /** + * Get Viva Engage group ID by community ID. + * @param communityId The ID of the Viva Engage community. + * @returns The ID of the Viva Engage group. + */ + async getEntraGroupIdByCommunityId(communityId: string): Promise { + const requestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + const community = await request.get(requestOptions); + + if (!community) { + throw `The specified Viva Engage community with ID '${communityId}' does not exist.`; + } + + return community.groupId; + }, + + /** + * Get Viva Engage group ID by community display name. + * @param displayName Community display name. + * @returns The ID of the Viva Engage group. + */ + async getEntraGroupIdByCommunityDisplayName(displayName: string): Promise { + const communityId = await this.getCommunityIdByDisplayName(displayName); + return await this.getEntraGroupIdByCommunityId(communityId); + } +}; \ No newline at end of file