Skip to content

Commit

Permalink
New command: viva engage community remove
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinM85 committed Sep 23, 2024
1 parent cbcf51c commit 75602cd
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 55 deletions.
15 changes: 12 additions & 3 deletions docs/docs/cmd/viva/engage/engage-community-remove.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ m365 viva engage community remove [options]

```md definition-list
`-i, --id [id]`
: The id of the community. Specify either `id` or `displayName` but not both.
: The id of the community. Specify either `id`, `displayName` or `entraGroupId`, but not multiple.

`-n, --displayName [displayName]`
: The name of the community. Specify either `id` or `displayName` but not both.
: The name of the community. Specify either `id`, `displayName` or `entraGroupId`, but not multiple.

`--entraGroupId [entraGroupId]`
: The id of the Microsoft Entra group associated with the community. Specify either `id`, `displayName` or `entraGroupId`, but not multiple.

`-f, --force`
: Don't prompt for confirmation.
Expand All @@ -35,7 +38,7 @@ When the Viva Engage community is removed, all the associated Microsoft 365 cont

## Examples

Remove a community specified by id without prompting
Remove a community specified by the community id without prompting

```sh
m365 viva engage community remove --id eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9 --force
Expand All @@ -47,6 +50,12 @@ Remove a community specified by name and prompt for confirmation
m365 viva engage community remove --displayName 'Software Engineers'
```

Remove a community specified by the Microsoft Entra group id without prompting

```sh
m365 viva engage community remove --entraGroupId 0bed8b86-5026-4a93-ac7d-56750cc099f1 --force
```

## Response

The command won't return a response on success
1 change: 1 addition & 0 deletions src/m365/viva/commands/engage/Community.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export interface Community {
displayName: string;
description?: string;
privacy: string;
groupId: string;
}
72 changes: 71 additions & 1 deletion src/m365/viva/commands/engage/engage-community-remove.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from 'assert';
import sinon from 'sinon';
import auth from '../../../../Auth.js';
import { z } from 'zod';
import commands from '../../commands.js';
import request from '../../../../request.js';
import { telemetry } from '../../../../telemetry.js';
Expand All @@ -12,21 +13,27 @@ 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';
import { CommandInfo } from '../../../../cli/CommandInfo.js';

describe(commands.ENGAGE_COMMUNITY_REMOVE, () => {
const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9';
const displayName = 'Software Engineers';
const entraGroupId = '0bed8b86-5026-4a93-ac7d-56750cc099f1';

let log: string[];
let logger: Logger;
let promptIssued: boolean;
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()!;
});

beforeEach(() => {
Expand All @@ -53,7 +60,6 @@ describe(commands.ENGAGE_COMMUNITY_REMOVE, () => {
afterEach(() => {
sinonUtil.restore([
request.delete,
vivaEngage.getCommunityIdByDisplayName,
cli.promptForConfirmation
]);
});
Expand All @@ -71,6 +77,52 @@ describe(commands.ENGAGE_COMMUNITY_REMOVE, () => {
assert.notStrictEqual(command.description, null);
});

it('fails validation if entraGroupId is not a valid GUID', () => {
const actual = commandOptionsSchema.safeParse({
entraGroupId: 'foo'
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if neither id nor displayName nor entraGroupId is specified', () => {
const actual = commandOptionsSchema.safeParse({
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if id, displayName and entraGroupId is specified', () => {
const actual = commandOptionsSchema.safeParse({
id: communityId,
displayName: displayName,
entraGroupId: entraGroupId
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if id and displayName', () => {
const actual = commandOptionsSchema.safeParse({
id: communityId,
displayName: displayName
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if displayName and entraGroupId', () => {
const actual = commandOptionsSchema.safeParse({
displayName: displayName,
entraGroupId: entraGroupId
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if id and entraGroupId is specified', () => {
const actual = commandOptionsSchema.safeParse({
id: communityId,
entraGroupId: entraGroupId
});
assert.notStrictEqual(actual.success, true);
});

it('prompts before removing the community when confirm option not passed', async () => {
await command.action(logger, { options: { id: communityId } });

Expand Down Expand Up @@ -115,6 +167,24 @@ describe(commands.ENGAGE_COMMUNITY_REMOVE, () => {
assert(deleteRequestStub.called);
});

it('removes the community specified by entraGroupId while prompting for confirmation', async () => {
sinon.stub(vivaEngage, 'getCommunityIdByEntraGroupId').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: { entraGroupId: entraGroupId } });
assert(deleteRequestStub.called);
});

it('throws an error when the community specified by id cannot be found', async () => {
const error = {
error: {
Expand Down
79 changes: 30 additions & 49 deletions src/m365/viva/commands/engage/engage-community-remove.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,64 @@
import GlobalOptions from '../../../../GlobalOptions.js';
import { z } from 'zod';
import { globalOptionsZod } from '../../../../Command.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 { zod } from '../../../../utils/zod.js';
import GraphCommand from '../../../base/GraphCommand.js';
import commands from '../../commands.js';
import { validation } from '../../../../utils/validation.js';

const options = globalOptionsZod
.extend({
id: zod.alias('i', z.string().optional()),
displayName: zod.alias('d', z.string().optional()),
entraGroupId: z.string().optional(),
force: z.boolean().optional()
})
.strict();
declare type Options = z.infer<typeof options>;

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'
}
);
public get schema(): z.ZodTypeAny | undefined {
return options;
}

#initOptionSets(): void {
this.optionSets.push(
{
options: ['id', 'displayName']
}
);
}

#initTypes(): void {
this.types.string.push('id', 'displayName');
public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | undefined {
return schema
.refine(options => Object.values([options.id, options.displayName, options.entraGroupId]).filter(x => typeof x !== 'undefined').length === 1, {
message: `Specify either id, displayName, or entraGroupId, but not multiple.`
})
.refine(options => (!options.id && !options.displayName && !options.entraGroupId) || options.id || options.displayName ||
(options.entraGroupId && validation.isValidGuid(options.entraGroupId)), options => ({
message: `The '${options.entraGroupId}' must be a valid GUID`,
path: ['entraGroupId']
}));
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {

const removeCommunity = async (): Promise<void> => {
try {
let communityId = args.options.id;

if (args.options.displayName) {
communityId = await vivaEngage.getCommunityIdByDisplayName(args.options.displayName);
}
else if (args.options.entraGroupId) {
communityId = await vivaEngage.getCommunityIdByEntraGroupId(args.options.entraGroupId);
}

if (args.options.verbose) {
await logger.logToStderr(`Removing Viva Engage community with ID ${communityId}...`);
Expand Down
36 changes: 34 additions & 2 deletions src/utils/vivaEngage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@ import { settingsNames } from '../settingsNames.js';
describe('utils/vivaEngage', () => {
const displayName = 'All Company';
const invalidDisplayName = 'All Compayn';
const entraGroupId = '0bed8b86-5026-4a93-ac7d-56750cc099f1';
const communityResponse = {
"id": "eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9",
"description": "This is the default group for everyone in the network",
"displayName": "All Company",
"privacy": "Public"
"privacy": "Public",
"groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1"
};
const anotherCommunityResponse = {
"id": "eyJfdHlwZ0NzY5SIwiIiSJ9IwO6IaWQiOIMTM1ODikdyb3Vw",
"description": "Test only",
"displayName": "All Company",
"privacy": "Private"
"privacy": "Private",
"groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1"
};

afterEach(() => {
Expand Down Expand Up @@ -105,4 +108,33 @@ describe('utils/vivaEngage', () => {
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.`);
});
});
19 changes: 19 additions & 0 deletions src/utils/vivaEngage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,24 @@ export const vivaEngage = {
}

return communities[0].id;
},

/**
* Get Viva Engage community ID by Entra group ID.
* @param entraGroupId The Microsoft Entra group ID.
* @returns The ID of the Viva Engage community.
* @throws Error when the community was not found.
*/
async getCommunityIdByEntraGroupId(entraGroupId: string): Promise<string> {
// The Graph API doesn't support filtering by groupId
const communities = await odata.getAllItems<Community>('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;
}
};

0 comments on commit 75602cd

Please sign in to comment.