From 135f53d1f2cad61f268581d6f6a552d39711a6ee Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 26 Jun 2026 14:58:52 +1000 Subject: [PATCH 1/2] feat(catering): support external catering backend in workplace bookings Add a settings-gated external catering backend that replaces the native PlaceOS catering system end-to-end in the workplace booking flow. When `app.catering.backend` is `external`, the booking menu is sourced from the external `Catering` driver's `products()` and orders are placed via `match_or_create_customer` + `create_external_order`. - ExternalCateringService (@placeos/common) wraps the driver, located via the `catering` org/building module binding, and maps external products to the native CateringItem (price -> cents). - catering-order-state menu hook reads the backend setting reactively so the menu reloads externally once org settings finish loading (avoids a load-order race that silently fell back to native). A load token guards against a slow stale load overwriting a newer one. - event-form places orders externally, then creates local `catering-order` booking records carrying an `external_reference` (customer uuid, purchase order number, raw response) so orders display in the management UI like native ones while the external platform remains the fulfilment system of record. Orders are only placed for new bookings: the driver has no update/cancel and keys on the event id, so re-submitting on edit would duplicate the order. - create_external_order is called with positional args (driver expects a String first param, not a single object), per the driver guide. PPT-2556 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/workplace/src/environments/settings.ts | 6 + .../catering-order-state.service.ts | 20 +- libs/common/src/index.ts | 1 + .../src/lib/external-catering.service.ts | 208 ++++++++++++++++++ .../tests/external-catering.service.spec.ts | 153 +++++++++++++ libs/events/src/lib/event-form.service.ts | 56 ++++- 6 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 libs/common/src/lib/external-catering.service.ts create mode 100644 libs/common/src/tests/external-catering.service.spec.ts diff --git a/apps/workplace/src/environments/settings.ts b/apps/workplace/src/environments/settings.ts index 4a6011f696..7b28b9f290 100644 --- a/apps/workplace/src/environments/settings.ts +++ b/apps/workplace/src/environments/settings.ts @@ -208,6 +208,12 @@ const app = { general, help, events, + // Catering backend: 'native' uses PlaceOS assets, 'external' uses the + // external catering driver bound to the building via the `catering` module + // binding + catering: { + backend: 'native', + }, space_display, directory, explore, diff --git a/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts b/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts index 57f09a2da1..31ee8cfdcb 100644 --- a/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts +++ b/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts @@ -2,6 +2,7 @@ import { computed, effect, inject, Injectable, signal } from '@angular/core'; import { queryCateringItems } from '@placeos/assets'; import { CateringItem, + ExternalCateringService, OrganisationService, SettingsService, Space, @@ -34,6 +35,7 @@ export interface CateringOrderSelectFilters { export class CateringOrderStateService { private _org = inject(OrganisationService); private _settings = inject(SettingsService); + private _external_catering = inject(ExternalCateringService); private _options = signal({}); private _filters = signal({ @@ -46,6 +48,8 @@ export class CateringOrderStateService { private _settings_data = signal({}); private _available_menu = signal([]); private _filtered_menu = signal([]); + /** Incrementing token so a stale menu load can't overwrite a newer one */ + private _menu_load = 0; public readonly loading = this._loading.asReadonly(); public readonly filters = this._filters.asReadonly(); @@ -75,6 +79,10 @@ export class CateringOrderStateService { effect(() => { const bld = this._org.active_building(); const { zone } = this._options(); + // Depend on the catering backend setting so the menu reloads via + // the external backend once the org settings finish loading (they arrive after the + // building is first set, so reading it non-reactively would race) + this._settings.signal('catering.backend')(); if (!bld?.id) return; this._loadSettings(bld.id); this._loadMenu(zone || bld.id); @@ -112,10 +120,16 @@ export class CateringOrderStateService { } private async _loadMenu(zone_id: string) { + const load = ++this._menu_load; this._loading.set('[MENU]'); - const items = await queryCateringItems(zone_id).catch( - () => [] as CateringItem[], - ); + const items = this._external_catering.enabled + ? await this._external_catering.loadMenu().catch(() => [] as CateringItem[]) + : await queryCateringItems(zone_id).catch( + () => [] as CateringItem[], + ); + // A newer load (e.g. native -> external after settings arrive) started + // while this one was in flight; drop this stale result. + if (load !== this._menu_load) return; this._loading.set(this._loading().replace('[MENU]', '')); if (this._settings.get('app.catering_provider')) { this.setFilters({ diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 4d232b13f3..d3cbff4574 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/booking-rules'; export * from './lib/common'; export * from './lib/currency-options'; export * from './lib/feature-available.guard'; +export * from './lib/external-catering.service'; export * from './lib/fixed-device-helpers'; export * from './lib/google-analytics.service'; export * from './lib/keep-alive.service'; diff --git a/libs/common/src/lib/external-catering.service.ts b/libs/common/src/lib/external-catering.service.ts new file mode 100644 index 0000000000..d8bccebe85 --- /dev/null +++ b/libs/common/src/lib/external-catering.service.ts @@ -0,0 +1,208 @@ +import { inject, Injectable } from '@angular/core'; +import { format } from 'date-fns'; + +import { OrganisationService } from './org/organisation.service'; +import { SettingsService } from './settings.service'; +import { CalendarEvent } from './types/event.class'; +import { CateringItem, CateringOrder } from './types/catering.class'; +import { currentUser } from './user-state'; + +/** Binding name used to resolve the external `Catering` driver module */ +const EXTERNAL_CATERING_BINDING = 'catering'; + +/** Subset of the external `Product` shape used by the frontend */ +interface ExternalProduct { + uuid: string; + name: string; + price: number | null; + description: string | null; + status: string | null; + product_images: { medium_image_url?: string; file_url?: string }[] | null; + product_categories: { name: string }[] | null; + dietaries: { code: string; name: string }[] | null; + supplier: { name: string } | null; +} + +interface ExternalProductsResponse { + items: ExternalProduct[]; + next_page: string | null; +} + +/** Subset of the external `Customer` shape used by the frontend */ +interface ExternalCustomer { + uuid: string; + email: string; + first_name: string | null; + last_name: string | null; +} + +/** Reference data linking a local catering order to its external counterpart */ +export interface ExternalOrderReference { + /** external catering customer record UUID for the booker */ + customer_uuid: string; + /** Customer display name sent with the order */ + customer_name: string; + /** Purchase order number sent to the external system (the PlaceOS event id) */ + purchase_order_number: string; + /** Raw response returned by the external `create_external_order` call */ + response: unknown; +} + +interface ExternalOrderItem { + product_name: string; + price: number; + quantity: number; + note?: string; +} + +/** + * Integration with the external catering platform via the PlaceOS `Catering` driver. + * + * When enabled (`app.catering.backend === 'external'`) this replaces the native + * PlaceOS catering system: the booking menu is sourced from external `products()` + * and orders are placed through `match_or_create_customer` + `create_external_order`. + */ +@Injectable({ + providedIn: 'root', +}) +export class ExternalCateringService { + private _org = inject(OrganisationService); + private _settings = inject(SettingsService); + + /** Whether the external backend should be used instead of native catering */ + public get enabled(): boolean { + return this._settings.get('app.catering.backend') === 'external'; + } + + private _module() { + const mod = this._org.module(EXTERNAL_CATERING_BINDING, 'Catering'); + if (!mod) { + throw new Error( + 'External catering enabled but no `catering` module binding is configured', + ); + } + return mod; + } + + /** Load the full catering menu from the external catering driver, mapped to native `CateringItem`s */ + public async loadMenu(): Promise { + const mod = this._module(); + const items: CateringItem[] = []; + let page = 1; + // ponytail: cap at 50 pages, matches the driver's own 100-page customer cap + while (page <= 50) { + const response = await mod + .execute('products', [page]) + .catch(() => null); + const products = response?.items || []; + for (const product of products) { + if (product.status && product.status !== 'active') continue; + items.push(toCateringItem(product)); + } + if (!response?.next_page || !products.length) break; + page += 1; + } + return items.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Place each catering order in the event against the external catering platform. + * + * Returns a external reference per placed order (aligned to the filtered list + * of orders that had items) so the caller can store local booking records + * that point back at the external order. + */ + public async placeOrders( + event: CalendarEvent, + orders: CateringOrder[], + ): Promise<{ order: CateringOrder; reference: ExternalOrderReference }[]> { + const valid = (orders || []).filter((order) => order.items.length); + if (!valid.length) return []; + const mod = this._module(); + const user = currentUser(); + // Idempotently resolve the external catering customer record for the booker. + // `match_or_create_customer(customer)` takes a single object param, so + // it is passed as the sole positional exec argument. + const customer = await mod.execute( + 'match_or_create_customer', + [ + { + email: user?.email || '', + first_name: user?.first_name || user?.name || '', + last_name: user?.last_name || '', + }, + ], + ); + const customer_name = + [customer?.first_name, customer?.last_name] + .filter((_) => !!_) + .join(' ') + .trim() || + customer?.email || + user?.name || + ''; + const delivery_address = + event.space?.display_name || + event.space?.name || + this._org.building?.display_name || + this._org.building?.name || + ''; + const placed: { order: CateringOrder; reference: ExternalOrderReference }[] = + []; + for (const order of valid) { + const order_items: ExternalOrderItem[] = order.items.map((item) => ({ + product_name: item.name, + price: centsToCurrency(item.unit_price_with_options), + quantity: item.quantity, + })); + // `create_external_order` takes POSITIONAL arguments (the first + // param is the purchase_order_number string) — passing a single + // object makes the driver fail to parse. Order matches the driver + // integration guide: required fields first, then the optionals. + const response = await mod.execute('create_external_order', [ + event.id, // purchase_order_number + customer_name, // customer_name + delivery_address, // delivery_address + format(order.deliver_at, "yyyy-MM-dd'T'HH:mm:ss"), // delivery_datetime + centsToCurrency(order.total_cost), // grand_total + order_items, // order_items + '', // company_name + '', // phone_number + event.attendees?.length || 0, // number_of_guests + 0, // delivery_fee + 0, // discount + order.notes || '', // delivery_instruction + ]); + placed.push({ + order, + reference: { + customer_uuid: customer?.uuid || '', + customer_name, + purchase_order_number: event.id, + response, + }, + }); + } + return placed; + } +} + +/** External prices are in major currency units; native `CateringItem`s store cents */ +function toCateringItem(product: ExternalProduct): CateringItem { + return new CateringItem({ + id: product.uuid, + name: product.name, + category: product.product_categories?.[0]?.name || '', + caterer: product.supplier?.name || '', + description: product.description || '', + unit_price: Math.round((product.price || 0) * 100), + images: (product.product_images || []) + .map((image) => image.medium_image_url || image.file_url || '') + .filter((_) => !!_), + tags: (product.dietaries || []).map((diet) => diet.name), + }); +} + +function centsToCurrency(cents: number): number { + return Math.round(cents) / 100; +} diff --git a/libs/common/src/tests/external-catering.service.spec.ts b/libs/common/src/tests/external-catering.service.spec.ts new file mode 100644 index 0000000000..e610fef17e --- /dev/null +++ b/libs/common/src/tests/external-catering.service.spec.ts @@ -0,0 +1,153 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { MockProvider } from 'ng-mocks'; + +import { ExternalCateringService } from '../lib/external-catering.service'; +import { OrganisationService } from '../lib/org/organisation.service'; +import { SettingsService } from '../lib/settings.service'; +import { CalendarEvent } from '../lib/types/event.class'; +import { CateringItem, CateringOrder } from '../lib/types/catering.class'; +import * as user_state from '../lib/user-state'; + +describe('ExternalCateringService', () => { + let spectator: SpectatorService; + let module: { execute: jest.Mock }; + let settings: Record; + + const createService = createServiceFactory({ + service: ExternalCateringService, + providers: [ + MockProvider(SettingsService, { + get: jest.fn((key: string) => settings[key]), + }), + MockProvider(OrganisationService, { + module: jest.fn(() => module as any), + building: { display_name: 'HQ' } as any, + }), + ], + }); + + beforeEach(() => { + settings = { 'app.catering.backend': 'external' }; + module = { execute: jest.fn() }; + spectator = createService(); + }); + + it('is enabled only when the backend setting is external', () => { + expect(spectator.service.enabled).toBe(true); + settings['app.catering.backend'] = 'native'; + expect(spectator.service.enabled).toBe(false); + }); + + it('maps external products to catering items in cents and drops inactive', async () => { + module.execute.mockResolvedValueOnce({ + next_page: null, + items: [ + { + uuid: 'p1', + name: 'Coffee', + price: 5, + status: 'active', + supplier: { name: 'Beans Co' }, + product_categories: [{ name: 'Drinks' }], + dietaries: [{ code: 'v', name: 'Vegan' }], + }, + { uuid: 'p2', name: 'Stale Cake', price: 3, status: 'archived' }, + ], + }); + const items = await spectator.service.loadMenu(); + expect(module.execute).toHaveBeenCalledWith('products', [1]); + expect(items).toHaveLength(1); + expect(items[0].name).toBe('Coffee'); + expect(items[0].unit_price).toBe(500); + expect(items[0].caterer).toBe('Beans Co'); + expect(items[0].category).toBe('Drinks'); + expect(items[0].tags).toEqual(['Vegan']); + }); + + it('walks every page of products', async () => { + module.execute + .mockResolvedValueOnce({ + next_page: 'http://external/p?page=2', + items: [{ uuid: 'a', name: 'A', price: 1, status: 'active' }], + }) + .mockResolvedValueOnce({ + next_page: null, + items: [{ uuid: 'b', name: 'B', price: 1, status: 'active' }], + }); + const items = await spectator.service.loadMenu(); + expect(module.execute).toHaveBeenCalledWith('products', [1]); + expect(module.execute).toHaveBeenCalledWith('products', [2]); + expect(items.map((_) => _.id)).toEqual(['a', 'b']); + }); + + it('places an order with a grand total in currency units', async () => { + jest.spyOn(user_state, 'currentUser').mockReturnValue({ + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + name: 'Jane Doe', + } as any); + module.execute + .mockResolvedValueOnce({ + uuid: 'cust-uuid-1', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + }) + .mockResolvedValueOnce({ id: 999, status: 'ok' }); + const event = new CalendarEvent({ + id: 'evt-1', + date: new Date('2026-07-01T10:00:00').valueOf(), + resources: [{ display_name: 'Boardroom' }] as any, + attendees: [{ email: 'a@x.com' }, { email: 'b@x.com' }] as any, + }); + const order = new CateringOrder({ + event, + notes: 'No nuts', + items: [ + new CateringItem({ + id: 'p1', + name: 'Coffee', + caterer: '', + unit_price: 500, + quantity: 2, + }), + ], + }); + const placed = await spectator.service.placeOrders(event, [order]); + + // returns a external reference per placed order for local booking records + expect(placed).toHaveLength(1); + expect(placed[0].reference).toEqual({ + customer_uuid: 'cust-uuid-1', + customer_name: 'Jane Doe', + purchase_order_number: 'evt-1', + response: { id: 999, status: 'ok' }, + }); + expect(placed[0].order).toBe(order); + + const [match_method, [customer]] = module.execute.mock.calls[0]; + expect(match_method).toBe('match_or_create_customer'); + expect(customer.email).toBe('jane@example.com'); + + // create_external_order takes POSITIONAL args, in driver-guide order + const [order_method, args] = module.execute.mock.calls[1]; + expect(order_method).toBe('create_external_order'); + const [po, name, address, datetime, grand_total, items] = args; + expect(po).toBe('evt-1'); + expect(name).toBe('Jane Doe'); + expect(address).toBe('Boardroom'); + expect(typeof datetime).toBe('string'); + expect(grand_total).toBe(10); + expect(items).toEqual([ + { product_name: 'Coffee', price: 5, quantity: 2 }, + ]); + expect(args[8]).toBe(2); // number_of_guests + expect(args[11]).toBe('No nuts'); // delivery_instruction + }); + + it('skips empty orders', async () => { + await spectator.service.placeOrders({} as any, []); + expect(module.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index ce3127754a..fe675416ff 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -19,6 +19,7 @@ import { currentUser, filterResourcesFromRules, firstValueWhere, + ExternalCateringService, flatten, getAllDayTimeRange, getInvalidSignalFields, @@ -102,6 +103,7 @@ export interface EventFormFilters { export class EventFormService extends AsyncHandler { private _org = inject(OrganisationService); private _settings = inject(SettingsService); + private _external_catering = inject(ExternalCateringService); private _router = inject(Router); private _assets = inject(AssetStateService); private _calendar = inject(CalendarService); @@ -937,14 +939,56 @@ export class EventFormService extends AsyncHandler { ); } // Create bookings for each catering order in the event + const is_new_booking = !event.id; if (this._model().catering?.length) { - await createBookingsForEvent( - created_event, - 'catering-order', - this._model().catering as any, - ).catch((e) => + let submit_catering: Promise | null = null; + if (this._external_catering.enabled) { + // The external catering driver only creates orders (no update/cancel) and + // keys them by purchase_order_number = event id, so + // re-submitting on an edit would duplicate the order. Only + // place orders for brand new bookings; existing external orders + // are left untouched on edit. + if (is_new_booking) { + submit_catering = this._external_catering + .placeOrders( + created_event, + this._model().catering as any, + ) + .then((placed) => { + // Create local catering-order bookings that + // reference the external order so they show in the + // management UI like native orders + const orders = placed.map( + ({ order, reference }) => { + const data = + typeof (order as any).toJSON === + 'function' + ? (order as any).toJSON() + : { ...order }; + return { + ...data, + external_reference: reference, + }; + }, + ); + if (!orders.length) return; + return createBookingsForEvent( + created_event, + 'catering-order', + orders as any, + ); + }); + } + } else { + submit_catering = createBookingsForEvent( + created_event, + 'catering-order', + this._model().catering as any, + ); + } + await submit_catering?.catch((e) => this._removeBookingAfterError( - !event.id, + is_new_booking, created_event, false, e, From 96fea51bde20156a18813b650da3644c435c1235 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 30 Jun 2026 12:13:57 +1000 Subject: [PATCH 2/2] fix(catering): fix infinite loop after adding order --- .../catering-order-state.service.ts | 4 +- libs/events/src/lib/event-form.service.ts | 60 ++++++++++++------- libs/events/src/lib/utilities.ts | 17 ++++-- libs/events/src/tests/utilities.spec.ts | 19 ++++++ 4 files changed, 73 insertions(+), 27 deletions(-) diff --git a/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts b/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts index 31ee8cfdcb..1eacadc713 100644 --- a/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts +++ b/libs/catering/src/lib/catering-order-modal/catering-order-state.service.ts @@ -123,7 +123,9 @@ export class CateringOrderStateService { const load = ++this._menu_load; this._loading.set('[MENU]'); const items = this._external_catering.enabled - ? await this._external_catering.loadMenu().catch(() => [] as CateringItem[]) + ? await this._external_catering + .loadMenu() + .catch(() => [] as CateringItem[]) : await queryCateringItems(zone_id).catch( () => [] as CateringItem[], ); diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index fe675416ff..861377cd9f 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -17,17 +17,17 @@ import { BookingRuleset, CalendarEvent, currentUser, + ExternalCateringService, filterResourcesFromRules, firstValueWhere, - ExternalCateringService, flatten, getAllDayTimeRange, getInvalidSignalFields, getTimeInTimezone, - isEmptyUser, - onFieldChange, i18n, + isEmptyUser, isWithinBookableHours, + onFieldChange, rulesForResource, setDefaultCreator, SettingsService, @@ -51,8 +51,8 @@ import { import { openRecurringClashModal } from 'libs/components/src/lib/recurring-clash-modal.component'; import { SpacePipe } from 'libs/events/src/lib/space.pipe'; import { requestSpacesForZone } from 'libs/events/src/lib/space.utilities'; -import { EventLinkModalComponent } from './event-link-modal.component'; import { CalendarService } from './calendar.service'; +import { EventLinkModalComponent } from './event-link-modal.component'; import { findEventClashes, querySpaceAvailability, @@ -222,9 +222,11 @@ export class EventFormService extends AsyncHandler { }, }); /** Signal for the booking rules of the active buildings, grouped by id */ - public readonly booking_rules = computed>(() => { - return this._booking_rules_resource.value() ?? {}; - }); + public readonly booking_rules = computed>( + () => { + return this._booking_rules_resource.value() ?? {}; + }, + ); /** Active zone used to load the bookable space list */ private readonly _space_zone = computed(() => { @@ -233,11 +235,10 @@ export class EventFormService extends AsyncHandler { : this._org.active_building(); return zone?.id || ''; }); - private readonly _space_zone_debounced = debounced( - this._space_zone, - 300, - { injector: this._injector, equal: Object.is }, - ); + private readonly _space_zone_debounced = debounced(this._space_zone, 300, { + injector: this._injector, + equal: Object.is, + }); /** Bookable spaces for the active zone */ private readonly _spaces_resource = resource({ params: () => @@ -441,7 +442,9 @@ export class EventFormService extends AsyncHandler { const previous = {}; effect( () => { - const { date: raw_date, duration: raw_duration } = this._model(); + console.log('Store Form'); + const { date: raw_date, duration: raw_duration } = + this._model(); if ( (raw_date && raw_date !== previous['date']) || (raw_duration && raw_duration !== previous['duration']) @@ -526,12 +529,23 @@ export class EventFormService extends AsyncHandler { }); const existing = this._availability_requests.get(key); if (existing) return existing; - const request = (this.book_internal - ? queryResourceAvailability(ids, date, duration, ignore, undefined) - : querySpaceAvailability(ids, date, duration, ignore, undefined, [ - event?.date, - event?.duration, - ]) + const request = ( + this.book_internal + ? queryResourceAvailability( + ids, + date, + duration, + ignore, + undefined, + ) + : querySpaceAvailability( + ids, + date, + duration, + ignore, + undefined, + [event?.date, event?.duration], + ) ).finally(() => this._availability_requests.delete(key)); this._availability_requests.set(key, request); return request; @@ -670,7 +684,10 @@ export class EventFormService extends AsyncHandler { public openEventLinkModal(force = false) { this._form().markAsTouched(); if (!this._form().valid() && !force) return; - const event = new CalendarEvent({ ...(this._model() as any), assets: [] }); + const event = new CalendarEvent({ + ...(this._model() as any), + assets: [], + }); const ref = this._dialog.open(EventLinkModalComponent, { data: event }); ref.afterClosed().subscribe((d) => d ? this._router.navigate(['/']) : '', @@ -1269,8 +1286,7 @@ export class EventFormService extends AsyncHandler { event.id, event.resources.length ? { - calendar: - this._model().host || currentUser()?.email, + calendar: this._model().host || currentUser()?.email, system_id: event.resources[0].id, } : {}, diff --git a/libs/events/src/lib/utilities.ts b/libs/events/src/lib/utilities.ts index 7d29dd419a..4c25e9e9f1 100644 --- a/libs/events/src/lib/utilities.ts +++ b/libs/events/src/lib/utilities.ts @@ -248,14 +248,23 @@ export function generateEventForm( const setCateringTime = () => { const value = model(); if (!value.catering?.length || !value.date) return; + const event = { + date: value.all_day ? startOfDay(value.date) : value.date, + duration: value.all_day ? 24 * 60 : value.duration, + }; + if ( + value.catering.every( + (order: any) => + +order.event?.date === +event.date && + order.event?.duration === event.duration, + ) + ) + return; model.update((m) => ({ ...m, catering: (m.catering || []).map((order: any) => ({ ...order, - event: { - date: m.all_day ? startOfDay(m.date) : m.date, - duration: m.all_day ? 24 * 60 : m.duration, - }, + event, })), })); }; diff --git a/libs/events/src/tests/utilities.spec.ts b/libs/events/src/tests/utilities.spec.ts index 7f2f5db35b..f76d73f921 100644 --- a/libs/events/src/tests/utilities.spec.ts +++ b/libs/events/src/tests/utilities.spec.ts @@ -97,6 +97,25 @@ describe('utilities', () => { expect(typeof form.host).toBe('function'); expect(model().host).toBeDefined(); }); + + it('should not loop when catering already has the current event time', () => { + const date = Date.now(); + const { model } = TestBed.runInInjectionContext(() => + generateEventForm( + new CalendarEvent({ date, duration: 30 } as any), + undefined, + injector, + ), + ); + const catering = [{ id: 'order-1', event: { date, duration: 30 } }]; + + model.update((m) => ({ ...m, catering })); + TestBed.flushEffects(); + const synced_catering = model().catering; + TestBed.flushEffects(); + + expect(model().catering).toBe(synced_catering); + }); }); describe('generateSystemsFormFields', () => {