diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a5c8232..9572c312 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/models/events.ts b/src/models/events.ts
index 5ef0c244..d9860e5f 100644
--- a/src/models/events.ts
+++ b/src/models/events.ts
@@ -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.
*/
@@ -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}
@@ -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 RFC-5545
@@ -222,8 +264,15 @@ export interface CreateEventRequest {
/**
* Interface representing a request to update an event.
- */
-export type UpdateEventRequest = Subset;
+ * 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,
+ 'resources'
+> & {
+ resources?: EventResource[];
+};
/**
* Interface representing a request to send RSVP to an event.
diff --git a/tests/resources/eventResources.spec.ts b/tests/resources/eventResources.spec.ts
new file mode 100644
index 00000000..d907e7aa
--- /dev/null
+++ b/tests/resources/eventResources.spec.ts
@@ -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;
+// 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[number];
+type _ResourceEmailRequired = Assert<
+ object extends Pick ? false : true
+>;
+type _UpdateResourceEmailRequired = Assert<
+ object extends Pick ? false : true
+>;
+type _NumericFloorNumber = Assert<
+ string extends NonNullable ? 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);
+ });
+});