diff --git a/libs/assets/src/lib/assets.fn.ts b/libs/assets/src/lib/assets.fn.ts index 3f361ab37d4..9f3b6508492 100644 --- a/libs/assets/src/lib/assets.fn.ts +++ b/libs/assets/src/lib/assets.fn.ts @@ -2,6 +2,7 @@ import { AssetGroup, AssetRequest, Booking, + CalendarEvent, flatten, unique, } from '@placeos/common'; @@ -31,6 +32,7 @@ import { bookedResourceList, BookingsQueryParams, createBooking, + parentBookingId, queryBookings, removeBooking, } from 'libs/bookings/src/lib/bookings.fn'; @@ -351,8 +353,21 @@ export function differenceBetweenAssetRequests( return changed; } +/** + * Check the asset requests of a parent against asset availability. Returns a + * function that removes the changed asset bookings and creates the new ones. + * The parent is a calendar event, or a native booking when `from_booking` or + * `from_bookings` is set + */ export async function validateAssetRequestsForResource( - { id, ical_uid, from_booking }: any, + { + id, + ical_uid, + from_booking, + from_bookings, + }: Partial> & { + from_booking?: boolean; + }, { date, duration, @@ -381,17 +396,25 @@ export async function validateAssetRequestsForResource( type: 'asset-request', zones: zones.join(','), }); + const native = !!(from_booking || from_bookings); + // The API has no filter for the parent booking, so a native query returns + // every asset request of the host on the day. Keep only this parent's const bookings = - id && ical_uid - ? await queryBookings({ - period_start: getUnixTime(startOfDay(date)), - period_end: getUnixTime(endOfDay(date)), - type: 'asset-request', - email: host, - event_id: from_booking ? '' : id, - booking_id: from_booking ? id : '', - ical_uid, - }) + id && (ical_uid || native) + ? ( + await queryBookings({ + period_start: getUnixTime(startOfDay(date)), + period_end: getUnixTime(endOfDay(date)), + type: 'asset-request', + email: host, + event_id: native ? '' : id, + ical_uid, + }) + ).filter( + (_) => + !native || + String(_.extension_data?.parent_id) === String(id), + ) : []; const booking_list: [string, AssetRequest][] = bookings.map((_) => [ _.id, @@ -504,12 +527,16 @@ export async function validateAssetRequestsForResource( }, zones: zones || [], }; - if (from_booking) (asset_data as any).parent_id = id; return () => - createBooking(new Booking(asset_data), { - ical_uid, - event_id: from_booking ? '' : id, - }); + native + ? createBooking({ + ...new Booking(asset_data).toJSON(), + parent_id: parentBookingId(id), + }) + : createBooking(new Booking(asset_data), { + ical_uid, + event_id: id, + }); }); return async () => { await Promise.all(changed_requests.map(([id]) => removeBooking(id))); diff --git a/libs/assets/src/tests/assets.fn.spec.ts b/libs/assets/src/tests/assets.fn.spec.ts new file mode 100644 index 00000000000..e9171d3f1cc --- /dev/null +++ b/libs/assets/src/tests/assets.fn.spec.ts @@ -0,0 +1,111 @@ +import { AssetRequest, setCurrentUser, StaffUser } from '@placeos/common'; +import { validateAssetRequestsForResource } from '../lib/assets.fn'; + +vi.mock('@placeos/ts-client', { spy: true }); + +import * as ts_client from '@placeos/ts-client'; + +describe('validateAssetRequestsForResource', () => { + const date = 1_800_000_000_000; + const period = { + date, + duration: 60, + all_day: false, + host: 'current.user@example.com', + zones: ['zone-1'], + }; + const request = new AssetRequest({ + id: 'request-1', + items: [ + { id: 'group-1', name: 'Projector', quantity: 1, item_ids: [] }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + setCurrentUser( + new StaffUser({ + id: 'current-user', + email: period.host, + name: 'Current User', + }), + ); + vi.spyOn(ts_client, 'queryAssetTypes').mockResolvedValue({ + total: 1, + next: null, + data: [{ id: 'group-1', name: 'Projector' }], + } as never); + vi.spyOn(ts_client, 'queryAssets').mockResolvedValue({ + total: 1, + next: null, + data: [{ id: 'unit-1', asset_type_id: 'group-1' }], + } as never); + vi.spyOn(ts_client, 'get').mockResolvedValue([] as never); + vi.spyOn(ts_client, 'post').mockResolvedValue({ + id: 'booking-1', + } as never); + }); + + afterEach(() => vi.useRealTimers()); + + it('should link a request to a native booking by parent id', async () => { + const create = await validateAssetRequestsForResource( + { id: '1138', ical_uid: '', from_bookings: true }, + period, + [request], + ); + await create(); + + const [url, body] = (ts_client.post as any).mock.calls[0] as [ + string, + any, + ]; + expect(url).not.toMatch(/event_id=\d/); + expect(body.parent_id).toBe(1138); + expect(body.asset_ids).toEqual(['unit-1']); + }); + + it('should keep the asset requests of other native bookings', async () => { + vi.spyOn(ts_client, 'get').mockResolvedValue([ + { + id: 'other-asset-booking', + booking_type: 'asset-request', + user_email: period.host, + approved: true, + asset_ids: ['unit-1'], + extension_data: { + parent_id: '9999', + request_id: 'other-request', + request: { id: 'other-request', items: [] }, + }, + }, + ] as never); + const delete_spy = vi.spyOn(ts_client, 'del'); + + const create = await validateAssetRequestsForResource( + { id: '1138', ical_uid: '', from_bookings: true }, + { ...period, reset_state: true }, + [], + ); + await create(); + + expect(delete_spy).not.toHaveBeenCalled(); + }); + + it('should link a request to a calendar event by event id', async () => { + const create = await validateAssetRequestsForResource( + { id: 'event-1', ical_uid: 'event-1@example.com' }, + period, + [request], + ); + await create(); + + const [url, body] = (ts_client.post as any).mock.calls[0] as [ + string, + any, + ]; + expect(url).toContain('event_id=event-1'); + expect(body.parent_id).toBeUndefined(); + }); +}); diff --git a/libs/bookings/src/lib/bookings.fn.ts b/libs/bookings/src/lib/bookings.fn.ts index dbc8832d09d..b5ad5095603 100644 --- a/libs/bookings/src/lib/bookings.fn.ts +++ b/libs/bookings/src/lib/bookings.fn.ts @@ -83,7 +83,7 @@ function bookingUtmSource() { return `${appName()}_${VERSION.hash}_${currentUser().email || ''}`; } -function withAppVersion(data: Partial): Partial { +function withAppVersion(data: NewBookingData): NewBookingData { const booking_data = { ...(data instanceof Booking ? data.toJSON() : data), }; @@ -235,12 +235,32 @@ export async function showBooking(id: string): Promise { ); } +/** + * Fields sent to create a booking. staff-api stores `parent_id` as an + * integer, while booking ids are typed as strings in this app + */ +export type NewBookingData = Omit, 'parent_id'> & { + parent_id?: string | number; +}; + +/** + * `parent_id` value that links a new booking to a parent booking. A numeric + * id is sent as the integer that staff-api reads. Other ids, such as the ones + * from the mock API, are sent unchanged + * @param id Id of the parent booking + */ +export function parentBookingId(id: string | number): string | number { + if (!id) throw new Error('Missing parent booking id'); + const parent_id = Number(id); + return Number.isSafeInteger(parent_id) ? parent_id : id; +} + /** * Create new booking and add it to the database * @param data New booking fields */ export async function createBooking( - data: Partial, + data: NewBookingData, q?: { event_id?: string; ical_uid?: string }, ): Promise { const query = toQueryString({ ...q, utm_source: bookingUtmSource() }); @@ -747,10 +767,11 @@ export async function isResourceAvailable( } /** - * Bookings of the given type that this app linked to the event. The API - * ignores the period when given an event id, so bookings are found wherever - * they sit in time; the period is only used by the mock API. Rejects when the - * request fails + * Bookings of the given type that this app linked to the event. A native + * booking event already holds its child bookings from the API. For a calendar + * event, the API ignores the period when given an event id, so bookings are + * found wherever they sit in time; the period is only used by the mock API. + * Rejects when the request fails * @param event Event the bookings are linked to * @param type Type of the linked bookings */ @@ -758,6 +779,11 @@ async function linkedBookingsForEvent( event: CalendarEvent, type: BookingType, ): Promise { + if (event.from_bookings) { + return event.linked_bookings + .filter((_) => _.booking_type === type) + .map((_) => new Booking(_ as unknown as Partial)); + } const bookings = await queryBookingsOrThrow({ type, event_id: event.id, @@ -771,7 +797,8 @@ async function linkedBookingsForEvent( /** * Ids of the linked bookings of the given type that were made for an earlier * copy of the event. A host change moves the event to the new host's calendar - * under a new id, and the API keeps the old host's bookings linked to it + * under a new id, and the API keeps the old host's bookings linked to it. A + * native booking keeps its id, so it has none * @param event Parent event * @param type Type of the linked bookings */ @@ -779,6 +806,7 @@ function replacedEventBookingIds( event: CalendarEvent, type: BookingType, ): string[] { + if (event.from_bookings) return []; return (event.linked_bookings || []) .filter((_) => { const parent_id = _.extension_data?.parent_id; @@ -957,10 +985,15 @@ export async function createBookingsForEvent( continue; } created_bookings.push( - await createBooking(desired.toJSON(), { - ical_uid: event.ical_uid, - event_id: event.id, - }), + event.from_bookings + ? await createBooking({ + ...desired.toJSON(), + parent_id: parentBookingId(event.id), + }) + : await createBooking(desired.toJSON(), { + ical_uid: event.ical_uid, + event_id: event.id, + }), ); } for (const booking of existing) { diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index e2918dcd5ce..b8c38eaa2f4 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -3654,6 +3654,14 @@ describe('BookingFormService', () => { it('should keep an existing booking when its asset requests fail', async () => { postBookings({ status: 422, error: 'Asset unavailable' }); + for (const method of [ts_client.patch, ts_client.put]) { + vi.mocked(method).mockImplementation( + async (_url: string, body: any) => ({ + ...body, + id: 'bkn-1', + }), + ); + } useDeskFormWithAssets( new Booking({ id: 'bkn-1', diff --git a/libs/bookings/src/test/booking.fn.spec.ts b/libs/bookings/src/test/booking.fn.spec.ts index 499f3f2778d..b8af0d8fc6b 100644 --- a/libs/bookings/src/test/booking.fn.spec.ts +++ b/libs/bookings/src/test/booking.fn.spec.ts @@ -398,6 +398,74 @@ describe('[Booking API]', () => { expect(delete_spy).not.toHaveBeenCalled(); }); + it('should link the bookings of a native booking event by parent id', async () => { + const native_event = new CalendarEvent({ + ...event.toJSON(), + id: '1138', + ical_uid: '', + from_bookings: true, + } as any); + const get_spy = vi + .spyOn(ts_client, 'get') + .mockResolvedValue([] as never); + const post_spy = vi + .spyOn(ts_client, 'post') + .mockResolvedValue({ id: 'visitor-booking-1' } as never); + + await createBookingsForEvent(native_event, 'visitor', [ + visitors[0], + ]); + + expect(get_spy).not.toHaveBeenCalled(); + const [url, body] = post_spy.mock.calls[0] as [string, any]; + expect(url).not.toContain('event_id='); + expect(url).not.toContain('ical_uid='); + expect(body.parent_id).toBe(1138); + expect(body.extension_data.parent_id).toBe('1138'); + }); + + it('should update the child of a native booking that another user hosts', async () => { + const host = 'host@example.com'; + const native_event = new CalendarEvent({ + ...event.toJSON(), + id: '1138', + host, + ical_uid: '', + from_bookings: true, + linked_bookings: [ + linked_visitor({ + parent_id: '1138', + user_email: host, + booking_start: 1_799_990_000, + booking_end: 1_799_993_600, + extension_data: { + parent_id: '1138', + name: 'Visitor One', + details: visitors[0], + }, + }), + ], + } as any); + // The API only lists the current user's bookings without filters + vi.spyOn(ts_client, 'get').mockResolvedValue([] as never); + const patch_spy = vi + .spyOn(ts_client, 'patch') + .mockResolvedValue({ id: 'visitor-booking-1' } as never); + const post_spy = vi.spyOn(ts_client, 'post'); + const delete_spy = vi.spyOn(ts_client, 'del'); + + await createBookingsForEvent(native_event, 'visitor', [ + visitors[0], + ]); + + expect(patch_spy).toHaveBeenCalledTimes(1); + expect(patch_spy.mock.calls[0][0]).toBe( + '/api/staff/v1/bookings/visitor-booking-1', + ); + expect(post_spy).not.toHaveBeenCalled(); + expect(delete_spy).not.toHaveBeenCalled(); + }); + it('should not create bookings when the linked bookings fail to load', async () => { const error = new Error('Unable to load bookings'); vi.spyOn(ts_client, 'get').mockRejectedValueOnce(error); diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index 33053dc7a02..fc581fb9d05 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -50,6 +50,7 @@ import { newBookingFromCalendarEvent } from 'libs/bookings/src/lib/booking.utili import { createBookingsForEvent, queryResourceAvailability, + removeBooking, saveBooking, } from 'libs/bookings/src/lib/bookings.fn'; import { openRecurringClashModal } from 'libs/components/src/lib/recurring-clash-modal.component'; @@ -1543,15 +1544,19 @@ export class EventFormService extends AsyncHandler { e, ) { if (is_new) { - await removeEvent( - event.id, - event.resources.length - ? { - calendar: this._model().host || currentUser()?.email, - system_id: event.resources[0].id, - } - : {}, - ); + // A native room booking is a staff-api booking, not an event + await (event.from_bookings + ? removeBooking(event.id) + : removeEvent( + event.id, + event.resources.length + ? { + calendar: + this._model().host || currentUser()?.email, + system_id: event.resources[0].id, + } + : {}, + )); throw e?.status === 409 ? i18n('CALENDAR_EVENT.ASSETS_CLASH_ERROR') : i18n('CALENDAR_EVENT.ASSETS_ERROR');