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
59 changes: 43 additions & 16 deletions libs/assets/src/lib/assets.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AssetGroup,
AssetRequest,
Booking,
CalendarEvent,
flatten,
unique,
} from '@placeos/common';
Expand Down Expand Up @@ -31,6 +32,7 @@ import {
bookedResourceList,
BookingsQueryParams,
createBooking,
parentBookingId,
queryBookings,
removeBooking,
} from 'libs/bookings/src/lib/bookings.fn';
Expand Down Expand Up @@ -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<Pick<CalendarEvent, 'id' | 'ical_uid' | 'from_bookings'>> & {
from_booking?: boolean;
},
{
date,
duration,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)));
Expand Down
111 changes: 111 additions & 0 deletions libs/assets/src/tests/assets.fn.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
55 changes: 44 additions & 11 deletions libs/bookings/src/lib/bookings.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function bookingUtmSource() {
return `${appName()}_${VERSION.hash}_${currentUser().email || ''}`;
}

function withAppVersion(data: Partial<Booking>): Partial<Booking> {
function withAppVersion(data: NewBookingData): NewBookingData {
const booking_data = {
...(data instanceof Booking ? data.toJSON() : data),
};
Expand Down Expand Up @@ -235,12 +235,32 @@ export async function showBooking(id: string): Promise<Booking> {
);
}

/**
* 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<Partial<Booking>, '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<Booking>,
data: NewBookingData,
q?: { event_id?: string; ical_uid?: string },
): Promise<Booking> {
const query = toQueryString({ ...q, utm_source: bookingUtmSource() });
Expand Down Expand Up @@ -747,17 +767,23 @@ 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
*/
async function linkedBookingsForEvent(
event: CalendarEvent,
type: BookingType,
): Promise<Booking[]> {
if (event.from_bookings) {
return event.linked_bookings
.filter((_) => _.booking_type === type)
.map((_) => new Booking(_ as unknown as Partial<Booking>));
}
const bookings = await queryBookingsOrThrow({
type,
event_id: event.id,
Expand All @@ -771,14 +797,16 @@ 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
*/
function replacedEventBookingIds(
event: CalendarEvent,
type: BookingType,
): string[] {
if (event.from_bookings) return [];
return (event.linked_bookings || [])
.filter((_) => {
const parent_id = _.extension_data?.parent_id;
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions libs/bookings/src/test/booking-form.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading