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..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 @@ -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,18 @@ 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..861377cd9f 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -17,16 +17,17 @@ import { BookingRuleset, CalendarEvent, currentUser, + ExternalCateringService, filterResourcesFromRules, firstValueWhere, flatten, getAllDayTimeRange, getInvalidSignalFields, getTimeInTimezone, - isEmptyUser, - onFieldChange, i18n, + isEmptyUser, isWithinBookableHours, + onFieldChange, rulesForResource, setDefaultCreator, SettingsService, @@ -50,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, @@ -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); @@ -220,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(() => { @@ -231,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: () => @@ -439,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']) @@ -524,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; @@ -668,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(['/']) : '', @@ -937,14 +956,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, @@ -1225,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', () => {