From 2f1b1bbf771fa79401445dc0cfbe39eb8f29d9b4 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 13 Oct 2026 11:40:29 -0400 Subject: [PATCH] CUST-5373: add event resource models --- CHANGELOG.md | 1 + src/models/events.ts | 53 +++++++- tests/resources/eventResources.spec.ts | 177 +++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 tests/resources/eventResources.spec.ts 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); + }); +});