Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Add `EventResource` and optional `resources` fields to event response, create, and update models for room/resource booking (CUST-5373)
- Add optional `trackingOptions.domainName` support for custom link and open tracking hostnames
- Add Contact metadata request/response types and `metadataPair` filtering, with contact webhook compatibility documentation

Expand Down
53 changes: 51 additions & 2 deletions src/models/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,40 @@ export interface NotetakerSettings {
meetingSettings?: NotetakerMeetingSettings;
}

/**
* A bookable resource associated with an event, such as a room or equipment.
*/
export interface EventResource {
/**
* The resource's email address.
*/
email: string;
/**
* The resource's display name.
*/
name?: string;
/**
* The maximum number of people the resource can accommodate.
*/
capacity?: number;
/**
* The building where the resource is located.
*/
building?: string;
/**
* The name of the floor where the resource is located.
*/
floorName?: string;
/**
* The section of the floor where the resource is located (Google only).
*/
floorSection?: string;
/**
* The floor number where the resource is located (Microsoft only).
*/
floorNumber?: number;
}

/**
* Interface representing a Nylas Event object.
*/
Expand Down Expand Up @@ -51,6 +85,10 @@ export interface Event {
* List of participants invited to the event. Participants may also be rooms or resources.
*/
participants: Participant[];
/**
* Bookable rooms or equipment associated with the event.
*/
resources?: EventResource[];
/**
* Representation of time and duration for events. When object can be in one of four formats (sub-objects):
* - {@link Date}
Expand Down Expand Up @@ -189,6 +227,10 @@ export interface CreateEventRequest {
* List of participants invited to the event. Participants may also be rooms or resources.
*/
participants?: Participant[];
/**
* Bookable rooms or equipment to associate with the event.
*/
resources?: EventResource[];
/**
* An list of RRULE and EXDATE strings.
* @see <a href="https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5">RFC-5545</a>
Expand Down Expand Up @@ -222,8 +264,15 @@ export interface CreateEventRequest {

/**
* Interface representing a request to update an event.
*/
export type UpdateEventRequest = Subset<CreateEventRequest>;
* Resources replace the existing list; each resource requires an email address.
* Omit resources to leave the list unchanged, or pass an empty array to clear it.
*/
export type UpdateEventRequest = Omit<
Subset<CreateEventRequest>,
'resources'
> & {
resources?: EventResource[];
};

/**
* Interface representing a request to send RSVP to an event.
Expand Down
177 changes: 177 additions & 0 deletions tests/resources/eventResources.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import fetchMock from 'jest-fetch-mock';
import APIClient from '../../src/apiClient';
import { Events } from '../../src/resources/events';
import type {
CreateEventRequest,
EventResource,
UpdateEventRequest,
} from '../../src/nylas';

type Assert<T extends true> = T;
// UpdateEventRequest uses a recursive partial for other fields, but each resource
// must still identify its email address when replacing the resources array.
type UpdateResource = NonNullable<UpdateEventRequest['resources']>[number];
type _ResourceEmailRequired = Assert<
object extends Pick<EventResource, 'email'> ? false : true
>;
type _UpdateResourceEmailRequired = Assert<
object extends Pick<UpdateResource, 'email'> ? false : true
>;
type _NumericFloorNumber = Assert<
string extends NonNullable<EventResource['floorNumber']> ? false : true
>;

describe('Event resources', () => {
const resources: EventResource[] = [
{
email: 'room@example.com',
name: 'Conference Room A',
capacity: 10,
building: 'HQ',
floorName: 'Second Floor',
floorSection: 'East Wing',
floorNumber: 2,
},
{ email: 'projector@example.com' },
];
const wireResources = [
{
email: 'room@example.com',
name: 'Conference Room A',
capacity: 10,
building: 'HQ',
floor_name: 'Second Floor',
floor_section: 'East Wing',
floor_number: 2,
},
{ email: 'projector@example.com' },
];
const eventJson = {
id: 'event-fixture',
grant_id: 'grant-fixture',
calendar_id: 'calendar-fixture',
object: 'event',
busy: true,
read_only: false,
participants: [],
visibility: 'public',
when: { time: 1719343231, timezone: 'UTC', object: 'time' },
};
const queryParams = { calendarId: 'calendar-fixture' };
let events: Events;

beforeEach(() => {
fetchMock.resetMocks();
events = new Events(
new APIClient({
apiKey: '',
apiUri: 'https://example.invalid',
timeout: 30,
headers: {},
})
);
});

it('creates an event with typed resources and converts nested fields', async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ data: { ...eventJson, resources: wireResources } })
);
const requestBody: CreateEventRequest = {
when: { time: 1719343231, timezone: 'UTC' },
resources,
};

const response = await events.create({
identifier: 'grant-fixture',
queryParams,
requestBody,
});

const request = fetchMock.mock.calls[0][0] as Request;
expect(request.method).toBe('POST');
expect(JSON.parse(await request.text()).resources).toEqual(wireResources);
expect(response.data.resources).toEqual(resources);
});

it('updates resources without requiring other event fields', async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ data: { ...eventJson, resources: wireResources } })
);
const requestBody: UpdateEventRequest = { resources };

const response = await events.update({
identifier: 'grant-fixture',
eventId: 'event-fixture',
queryParams,
requestBody,
});

const request = fetchMock.mock.calls[0][0] as Request;
expect(request.method).toBe('PUT');
expect(JSON.parse(await request.text())).toEqual({
resources: wireResources,
});
expect(response.data.resources).toEqual(resources);
});

it('sends an empty resources array to clear existing resources', async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ data: { ...eventJson, resources: [] } })
);

const response = await events.update({
identifier: 'grant-fixture',
eventId: 'event-fixture',
queryParams,
requestBody: { resources: [] },
});

const request = fetchMock.mock.calls[0][0] as Request;
expect(JSON.parse(await request.text())).toEqual({ resources: [] });
expect(response.data.resources).toEqual([]);
});

it('omits resources when updating unrelated event fields', async () => {
fetchMock.mockResponseOnce(JSON.stringify({ data: eventJson }));

const response = await events.update({
identifier: 'grant-fixture',
eventId: 'event-fixture',
queryParams,
requestBody: { title: 'Updated title' },
});

const request = fetchMock.mock.calls[0][0] as Request;
expect(JSON.parse(await request.text())).toEqual({
title: 'Updated title',
});
expect(response.data.resources).toBeUndefined();
});

it('reads resources from a single event', async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ data: { ...eventJson, resources: wireResources } })
);

const response = await events.find({
identifier: 'grant-fixture',
eventId: 'event-fixture',
queryParams,
});

expect(response.data.resources).toEqual(resources);
});

it('reads resources from listed events', async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ data: [{ ...eventJson, resources: wireResources }] })
);

const response = await events.list({
identifier: 'grant-fixture',
queryParams,
});

expect(response.data[0].resources).toEqual(resources);
});
});
Loading