-
Notifications
You must be signed in to change notification settings - Fork 410
Adds new command outlook event add. Closes #7123
#7478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MartinM85
wants to merge
2
commits into
pnp:main
Choose a base branch
from
MartinM85:feature/7123-outlook-event-add
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1,027 changes: 1,027 additions & 0 deletions
1,027
src/m365/outlook/commands/event/event-add.spec.ts
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,358 @@ | ||
| import { Event } from '@microsoft/microsoft-graph-types'; | ||
| import { z } from 'zod'; | ||
| import { Logger } from '../../../../cli/Logger.js'; | ||
| import GraphCommand from '../../../base/GraphCommand.js'; | ||
| import commands from '../../commands.js'; | ||
| import { validation } from '../../../../utils/validation.js'; | ||
| import { globalOptionsZod } from '../../../../Command.js'; | ||
| import request, { CliRequestOptions } from '../../../../request.js'; | ||
| import { calendar } from '../../../../utils/calendar.js'; | ||
| import fs from 'fs'; | ||
| import { accessToken } from '../../../../utils/accessToken.js'; | ||
| import auth from '../../../../Auth.js'; | ||
|
|
||
| const bodyContentTypes = ['Text', 'HTML'] as const; | ||
| const importances = ['low', 'normal', 'high'] as const; | ||
| const onlineMeetingProviders = ['teamsForBusiness', 'skypeForBusinnes', 'skypeForConsumer'] as const; | ||
| const sensitivities = ['normal', 'personal', 'private', 'confidential'] as const; | ||
| const showAsStatuses = ['free', 'tentative', 'busy', 'oof', 'workingElsewhere'] as const; | ||
|
|
||
| export const options = z.strictObject({ | ||
| ...globalOptionsZod.shape, | ||
| subject: z.string(), | ||
| start: z.string().refine(date => validation.isValidGraphDateTime(date), { | ||
| error: e => `'${e.input}' is not a valid date.` | ||
| }), | ||
| end: z.string().refine(date => validation.isValidGraphDateTime(date), { | ||
| error: e => `'${e.input}' is not a valid date.` | ||
| }), | ||
| userId: z.string().refine(id => validation.isValidGuid(id), { | ||
| error: e => `'${e.input}' is not a valid GUID.` | ||
| }).optional(), | ||
| userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { | ||
| error: e => `'${e.input}' is not a valid UPN.` | ||
| }).optional(), | ||
| calendarId: z.string().optional(), | ||
| calendarName: z.string().optional(), | ||
| allowNewTimeProposals: z.boolean().optional().default(true), | ||
| bodyContents: z.string().optional(), | ||
| bodyContentType: z.preprocess(val => { | ||
| const target = String(val).toLowerCase(); | ||
| return bodyContentTypes.find(t => t.toLowerCase() === target) ?? val; | ||
| }, z.enum(bodyContentTypes)).optional(), | ||
| categories: z.string().transform((value) => value.split(',').map(String)).optional(), | ||
| hideAttendees: z.boolean().optional().default(false), | ||
| importance: z.preprocess(val => { | ||
| const target = String(val).toLowerCase(); | ||
| return importances.find(t => t.toLowerCase() === target) ?? val; | ||
| }, z.enum(importances)).optional(), | ||
| isAllDay: z.boolean().optional().default(false), | ||
| isOnlineMeeting: z.boolean().optional().default(false), | ||
| isReminderOn: z.boolean().optional().default(true), | ||
| location: z.string().optional(), | ||
| locationEmailAddress: z.string().refine(name => validation.isValidUserPrincipalName(name), { | ||
| error: e => `'${e.input}' is not a valid email address.` | ||
| }).optional(), | ||
| locations: z.string().transform((value) => value.split(',').map(String)).optional(), | ||
| onlineMeetingProvider: z.preprocess(val => { | ||
| const target = String(val).toLowerCase(); | ||
| return onlineMeetingProviders.find(t => t.toLowerCase() === target) ?? val; | ||
| }, z.enum(onlineMeetingProviders)).optional(), | ||
| optionalAttendees: z.string() | ||
| .refine(names => validation.isValidUserPrincipalNameArray(names) === true, { | ||
| error: e => `The following attendees names are invalid for the option 'optionalAttendees': ${validation.isValidUserPrincipalNameArray(e.input as string)}.` | ||
| }).transform((value) => value.split(',').map(String)).optional(), | ||
| recurrence: z.string().optional(), | ||
| reminderMinutesBeforeStart: z.number().refine(minutes => minutes >= 0, { | ||
| error: () => 'The number of reminder minutes must be a positive number or 0' | ||
| }).optional(), | ||
| requiredAttendees: z.string() | ||
| .refine(names => validation.isValidUserPrincipalNameArray(names) === true, { | ||
| error: e => `The following attendees names are invalid for the option 'requiredAttendees': ${validation.isValidUserPrincipalNameArray(e.input as string)}.` | ||
| }).transform((value) => value.split(',').map(String)).optional(), | ||
| resources: z.string() | ||
| .refine(names => validation.isValidUserPrincipalNameArray(names) === true, { | ||
| error: e => `The following attendees names are invalid for the option 'resources': ${validation.isValidUserPrincipalNameArray(e.input as string)}.` | ||
| }).transform((value) => value.split(',').map(String)).optional(), | ||
| responseRequested: z.boolean().optional().default(true), | ||
| sensitivity: z.preprocess(val => { | ||
| const target = String(val).toLowerCase(); | ||
| return sensitivities.find(t => t.toLowerCase() === target) ?? val; | ||
| }, z.enum(sensitivities)).optional(), | ||
| showAs: z.preprocess(val => { | ||
| const target = String(val).toLowerCase(); | ||
| return showAsStatuses.find(t => t.toLowerCase() === target) ?? val; | ||
| }, z.enum(showAsStatuses)).optional(), | ||
| timeZone: z.string().optional().default('UTC'), | ||
| transactionId: z.string().optional() | ||
| }); | ||
|
|
||
| declare type Options = z.infer<typeof options>; | ||
|
|
||
| interface CommandArgs { | ||
| options: Options; | ||
| } | ||
|
|
||
| class OutlookEventAddCommand extends GraphCommand { | ||
| public get name(): string { | ||
| return commands.EVENT_ADD; | ||
| } | ||
|
|
||
| public get description(): string { | ||
| return `Create an event in the default calendar or a specific calendar of a user`; | ||
| } | ||
|
|
||
| public get schema(): z.ZodType | undefined { | ||
| return options; | ||
| } | ||
|
|
||
| public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined { | ||
| return schema | ||
| .refine(options => !(options.calendarId && options.calendarName), { | ||
| error: 'Specify either calendarId or calendarName, but not both.' | ||
| }) | ||
| .refine(options => !(options.location && options.locations), { | ||
| error: 'Specify either location or locations, but not both.' | ||
| }) | ||
| .refine(options => !(options.userId && options.userName), { | ||
| error: 'Specify either userId or userName, but not both.' | ||
| }) | ||
| .refine(options => !(options.isAllDay && (options.start.endsWith('T00:00:00') || options.end.endsWith('T00:00:00'))), { | ||
|
MartinM85 marked this conversation as resolved.
Outdated
|
||
| error: 'When isAllDay is true, start and end must be set to midnight.' | ||
| }) | ||
| .refine(options => !(options.reminderMinutesBeforeStart && !options.isReminderOn), { | ||
|
MartinM85 marked this conversation as resolved.
Outdated
|
||
| error: 'When reminderMinutesBeforeStart is specified, isReminderOn must be true.' | ||
| }) | ||
| .refine(options => !(options.locationEmailAddress && !options.location), { | ||
| error: 'When locationEmailAddress is specified, location must be also specified.' | ||
| }) | ||
| .refine(options => new Date(options.start).getTime() < new Date(options.end).getTime(), { | ||
| error: 'Start date must be before end date.' | ||
| }); | ||
| } | ||
|
|
||
| public async commandAction(logger: Logger, args: CommandArgs): Promise<void> { | ||
| const isAppOnlyAccessToken: boolean | undefined = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken); | ||
| let principalUrl = ''; | ||
|
|
||
| const token = auth.connection.accessTokens[auth.defaultResource].accessToken; | ||
|
|
||
| if (isAppOnlyAccessToken) { | ||
| if (!args.options.userId && !args.options.userName) { | ||
| throw `The option 'userId' or 'userName' is required when creating an event using application permissions.`; | ||
| } | ||
| } | ||
| else { | ||
| if (args.options.userId) { | ||
| const currentUserId = accessToken.getUserIdFromAccessToken(token); | ||
| if (args.options.userId !== currentUserId) { | ||
| throw `You can only create your own events when using delegated permissions. The specified userId '${args.options.userId}' does not match the current user '${currentUserId}'.`; | ||
| } | ||
| } | ||
|
|
||
| if (args.options.userName) { | ||
| const currentUserName = accessToken.getUserNameFromAccessToken(token); | ||
| if (args.options.userName.toLowerCase() !== currentUserName.toLowerCase()) { | ||
| throw `You can only create your own events when using delegated permissions. The specified userName '${args.options.userName}' does not match the current user '${currentUserName}'.`; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let userIdentifier: string | undefined; | ||
| if (args.options.userId || args.options.userName) { | ||
| userIdentifier = args.options.userId ?? args.options.userName; | ||
| principalUrl += `users('${userIdentifier}')`; | ||
| } | ||
| else { | ||
| userIdentifier = accessToken.getUserNameFromAccessToken(token); | ||
| principalUrl += 'me'; | ||
| } | ||
|
|
||
| if (this.verbose) { | ||
| await logger.logToStderr(`Creating event for user ${userIdentifier}...`); | ||
| } | ||
|
|
||
| let calendarId = args.options.calendarId; | ||
| if (args.options.calendarName) { | ||
| calendarId = (await calendar.getUserCalendarByName(userIdentifier!, args.options.calendarName))!.id; | ||
| } | ||
|
|
||
| let requestUrl: string = `${this.resource}/v1.0/${principalUrl}`; | ||
|
|
||
| if (calendarId) { | ||
| requestUrl += `/calendars/${calendarId}/events`; | ||
| } | ||
| else { | ||
| requestUrl += '/events'; | ||
| } | ||
|
|
||
| const body : any = {}; | ||
| body['subject'] = args.options.subject; | ||
| body['start'] = { | ||
| dateTime: args.options.start | ||
| }; | ||
| body['end'] = { | ||
| dateTime: args.options.end | ||
| }; | ||
|
|
||
| if (args.options.bodyContentType || args.options.bodyContents) { | ||
| body['body'] = {}; | ||
|
|
||
| if (args.options.bodyContentType) { | ||
| body['body']['contentType'] = args.options.bodyContentType; | ||
| } | ||
|
|
||
| if (args.options.bodyContents) { | ||
| if (args.options.bodyContents.startsWith('@')) { | ||
| const fileBodyContents: string = fs.readFileSync(args.options.bodyContents.replace('@', ''), 'utf8'); | ||
| if (fileBodyContents) { | ||
| body['body']['content'] = fileBodyContents; | ||
| } | ||
| } | ||
| else { | ||
| body['body']['content'] = args.options.bodyContents; | ||
| } | ||
| } | ||
| } | ||
|
MartinM85 marked this conversation as resolved.
|
||
|
|
||
| if (!args.options.allowNewTimeProposals) { | ||
| body['allowNewTimeProposals'] = args.options.allowNewTimeProposals; | ||
| } | ||
|
|
||
| if (args.options.categories) { | ||
| body['categories'] = args.options.categories; | ||
| } | ||
|
|
||
| if (args.options.hideAttendees) { | ||
| body['hideAttendees'] = args.options.hideAttendees; | ||
| } | ||
|
|
||
| if (args.options.importance) { | ||
| body['importance'] = args.options.importance; | ||
| } | ||
|
|
||
| if (args.options.isAllDay) { | ||
| body['isAllDay'] = args.options.isAllDay; | ||
| } | ||
|
|
||
| if (args.options.isOnlineMeeting) { | ||
| body['isOnlineMeeting'] = args.options.isOnlineMeeting; | ||
| } | ||
|
|
||
| if (!args.options.isReminderOn) { | ||
| body['isReminderOn'] = false; | ||
| } | ||
|
|
||
| if (args.options.location || args.options.locationEmailAddress) { | ||
| body['location'] = {}; | ||
|
|
||
| if (args.options.location) { | ||
| body['location']['displayName'] = args.options.location; | ||
| } | ||
|
|
||
| if (args.options.locationEmailAddress) { | ||
| body['location']['locationEmailAddress'] = args.options.locationEmailAddress; | ||
| } | ||
| } | ||
|
|
||
| if (args.options.locations) { | ||
| const locations: Array<any> = []; | ||
|
|
||
| args.options.locations.forEach(displayName => locations.push({ | ||
| displayName: displayName | ||
| })); | ||
|
|
||
| body['locations'] = locations; | ||
| } | ||
|
|
||
| if (args.options.onlineMeetingProvider) { | ||
| body['onlineMeetingProvider'] = args.options.onlineMeetingProvider; | ||
| } | ||
|
|
||
| if (args.options.optionalAttendees || args.options.requiredAttendees || args.options.resources) { | ||
| body['attendees'] = []; | ||
| const attendees = body['attendees'] as Array<any>; | ||
|
|
||
| if (args.options.optionalAttendees) { | ||
| args.options.optionalAttendees.forEach(value => | ||
| attendees.push({ | ||
| emailAddress: value, | ||
|
MartinM85 marked this conversation as resolved.
Outdated
|
||
| type: 'optional' | ||
| })); | ||
| } | ||
|
|
||
| if (args.options.requiredAttendees) { | ||
| args.options.requiredAttendees.forEach(value => | ||
| attendees.push({ | ||
| emailAddress: value, | ||
| type: 'required' | ||
| })); | ||
| } | ||
|
|
||
| if (args.options.resources) { | ||
| args.options.resources.forEach(value => | ||
| attendees.push({ | ||
| emailAddress: value, | ||
| type: 'resource' | ||
| })); | ||
| } | ||
| } | ||
|
|
||
| if (args.options.recurrence) { | ||
| if (args.options.recurrence.startsWith('@')) { | ||
| const fileRecurrence: string = fs.readFileSync(args.options.recurrence.replace('@', ''), 'utf8'); | ||
| if (fileRecurrence) { | ||
| body['recurrence'] = JSON.parse(fileRecurrence); | ||
| } | ||
| } | ||
| else { | ||
| body['recurrence'] = JSON.parse(args.options.recurrence); | ||
| } | ||
| } | ||
|
|
||
| if (args.options.isReminderOn && args.options.reminderMinutesBeforeStart) { | ||
| body['reminderMinutesBeforeStart'] = args.options.reminderMinutesBeforeStart; | ||
| } | ||
|
|
||
| if (!args.options.responseRequested) { | ||
| body['responseRequested'] = false; | ||
| } | ||
|
|
||
| if (args.options.sensitivity) { | ||
| body['sensitivity'] = args.options.sensitivity; | ||
| } | ||
|
|
||
| if (args.options.showAs) { | ||
| body['showAs'] = args.options.showAs; | ||
| } | ||
|
|
||
| if (args.options.timeZone) { | ||
| body['start']['timeZone'] = args.options.timeZone; | ||
| body['end']['timeZone'] = args.options.timeZone; | ||
| } | ||
|
|
||
| if (args.options.transactionId) { | ||
| body['transactionId'] = args.options.transactionId; | ||
| } | ||
|
|
||
| const requestOptions: CliRequestOptions = { | ||
| url: requestUrl, | ||
| headers: { | ||
| accept: 'application/json;odata.metadata=none', | ||
| 'content-type': 'application/json' | ||
| }, | ||
| responseType: 'json', | ||
| data: body | ||
| }; | ||
|
|
||
| try { | ||
| const result = await request.post<Event>(requestOptions); | ||
| await logger.log(result); | ||
| } | ||
| catch (err: any) { | ||
| this.handleRejectedODataJsonPromise(err); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export default new OutlookEventAddCommand(); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.