From 399b81182f17277f646bb7724c5b25ba6f9bebf2 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 8 Oct 2026 14:29:37 +1100 Subject: [PATCH] refactor: reduce complexity in the top fallow refactoring targets Split large services and utility files along cohesive seams, extract complex functions into named helpers, and remove duplicated logic. Public library APIs do not change. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/concierge/src/app/desks/desk-edit.ts | 49 + .../src/app/desks/desks-state.service.ts | 305 +++-- .../room-manager/room-management.service.ts | 25 +- libs/bookings/src/index.ts | 2 + .../src/lib/booking-card.component.ts | 172 +-- libs/bookings/src/lib/booking-form.model.ts | 351 ++++++ libs/bookings/src/lib/booking-form.payload.ts | 118 ++ libs/bookings/src/lib/booking-form.service.ts | 1112 ++++++----------- libs/bookings/src/lib/booking-form.types.ts | 50 + .../src/lib/booking-group.utilities.ts | 193 +++ .../src/lib/booking-resource.loaders.ts | 113 ++ .../src/lib/booking-resource.utilities.ts | 126 ++ libs/bookings/src/lib/booking.utilities.ts | 351 +----- .../src/lib/desk-list-field.component.ts | 2 +- .../desk-details.component.ts | 2 +- .../desk-select-modal/desk-list.component.ts | 3 +- .../desk-select-modal/desk-map.component.ts | 3 +- .../desk-select-modal.component.ts | 7 +- .../src/lib/invite-visitor-form.component.ts | 2 +- .../src/lib/locker-list-field.component.ts | 2 +- .../locker-bank-list.component.ts | 3 +- .../locker-map.component.ts | 3 +- .../locker-select-modal.component.ts | 7 +- .../parking-details.component.ts | 2 +- .../parking-list.component.ts | 3 +- .../parking-map.component.ts | 3 +- .../parking-select-modal.component.ts | 7 +- .../lib/parking-space-list-field.component.ts | 2 +- .../src/test/booking.utilities.spec.ts | 2 +- .../src/test/desk-filters.component.spec.ts | 2 +- .../invite-visitor-form.component.spec.ts | 2 +- .../src/test/locker-filters.component.spec.ts | 2 +- .../src/test/parking-list.component.spec.ts | 3 +- .../src/test/parking-map.component.spec.ts | 6 +- .../parking-select-modal.component.spec.ts | 3 +- .../visitor-invite-form.component.spec.ts | 2 +- libs/catering/src/index.ts | 1 + .../lib/catering-kitchen-board.component.ts | 7 +- .../src/lib/catering-order-details.ts | 59 + .../lib/catering-order-dockets.component.ts | 2 +- libs/catering/src/lib/catering-order-tools.ts | 61 +- .../src/lib/catering-orders.service.ts | 8 +- .../lib/catering-prep-summary.component.ts | 8 +- libs/common/src/lib/types/booking.class.ts | 244 ++-- libs/events/src/lib/event-form-changes.ts | 82 ++ libs/events/src/lib/event-form.service.ts | 405 +----- libs/events/src/lib/event-form.ts | 282 +++++ libs/events/src/lib/event-save.fn.ts | 330 +++++ libs/events/src/lib/events.ts | 1 + libs/events/src/lib/utilities.ts | 277 +--- libs/events/src/tests/utilities.spec.ts | 2 +- libs/explore/src/lib/explore-booking-rules.ts | 19 + libs/explore/src/lib/explore-desks.service.ts | 10 +- .../src/lib/explore-parking.service.ts | 321 +++-- .../explore/src/lib/explore-spaces.service.ts | 10 +- .../src/lib/set-datetime-modal.component.ts | 2 +- .../src/tests/explore-desks.service.spec.ts | 2 +- .../set-datetime-modal.component.spec.ts | 2 +- 58 files changed, 2804 insertions(+), 2371 deletions(-) create mode 100644 apps/concierge/src/app/desks/desk-edit.ts create mode 100644 libs/bookings/src/lib/booking-form.model.ts create mode 100644 libs/bookings/src/lib/booking-form.payload.ts create mode 100644 libs/bookings/src/lib/booking-form.types.ts create mode 100644 libs/bookings/src/lib/booking-group.utilities.ts create mode 100644 libs/bookings/src/lib/booking-resource.loaders.ts create mode 100644 libs/bookings/src/lib/booking-resource.utilities.ts create mode 100644 libs/catering/src/lib/catering-order-details.ts create mode 100644 libs/events/src/lib/event-form-changes.ts create mode 100644 libs/events/src/lib/event-form.ts create mode 100644 libs/events/src/lib/event-save.fn.ts create mode 100644 libs/explore/src/lib/explore-booking-rules.ts diff --git a/apps/concierge/src/app/desks/desk-edit.ts b/apps/concierge/src/app/desks/desk-edit.ts new file mode 100644 index 0000000000..1b42f3120f --- /dev/null +++ b/apps/concierge/src/app/desks/desk-edit.ts @@ -0,0 +1,49 @@ +import { MatDialogRef } from '@angular/material/dialog'; +import { Desk, DialogEvent, nextValueFrom } from '@placeos/common'; +import { DeskModalComponent } from './desk-modal.component'; + +/** Desk fields from the desk modal, with the level that the user selected. */ +export type DeskModalValue = Partial & { zone_id?: string }; + +/** + * Wait for the desk modal to finish. Resolves with the first "done" event, + * or with the close value if the modal closes first. + */ +export function deskModalResult( + ref: MatDialogRef, +): Promise | undefined> { + return Promise.race([ + nextValueFrom(ref.afterClosed()), + new Promise>((resolve) => { + const sub = ref.componentInstance.event.subscribe((event) => { + if (event?.reason !== 'done') return; + sub.unsubscribe(); + resolve(event); + }); + }), + ]); +} + +/** Whether an edit moved the desk's assignment to a new user or desk ID. */ +export function assignmentChanged(before: Desk, after: Desk): boolean { + return before.assigned_to !== after.assigned_to || before.id !== after.id; +} + +/** Copy of `list` with the desk that has `id` replaced, or `desk` appended. */ +export function upsertDesk(list: Desk[], id: string, desk: Desk): Desk[] { + const updated = [...list]; + const idx = updated.findIndex((_) => _.id === id); + if (idx >= 0) updated[idx] = desk; + else updated.push(desk); + return updated; +} + +/** Whether a request failed with an HTTP 409 conflict. */ +export function isConflict(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'status' in error && + error.status === 409 + ); +} diff --git a/apps/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index c58ef1df3e..d96b7d92b8 100644 --- a/apps/concierge/src/app/desks/desks-state.service.ts +++ b/apps/concierge/src/app/desks/desks-state.service.ts @@ -8,7 +8,7 @@ import { Signal, signal, } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { deleteDeskAsset, deskFromAsset, @@ -38,7 +38,6 @@ import { getTimezoneDifferenceInHours, i18n, jsonToCsv, - nextValueFrom, notifyError, notifyInfo, notifySuccess, @@ -51,6 +50,7 @@ import { } from '@placeos/common'; import { listChildMetadata, + PlaceZone, QueryResponse, showMetadata, updateMetadata, @@ -71,6 +71,12 @@ import { canChangeDeskBooking, isDeskBookingRejected, } from './desk-booking-actions'; +import { + assignmentChanged, + deskModalResult, + isConflict, + upsertDesk, +} from './desk-edit'; import { DeskModalComponent } from './desk-modal.component'; function addQRCodeToBooking(booking: Booking): Booking { @@ -526,24 +532,19 @@ export class DesksStateService extends AsyncHandler { this._change.set(Date.now()); } + /** + * Open the desk modal and save the result. Moves the desk's assigned + * booking when the assignee or ID changes, and rolls back the desk save + * when that fails. + */ public async editDesk(desk: Desk = new Desk()) { const levels = this._currentLevelList(); - const selected_zones = this._getSelectedZones(); const zone_id = - desk.zone?.id || selected_zones[0] || levels[0]?.id || ''; + desk.zone?.id || this._getSelectedZones()[0] || levels[0]?.id || ''; const ref = this._dialog.open(DeskModalComponent, { data: { desk, levels, zone_id }, }); - const state = await Promise.race([ - nextValueFrom(ref.afterClosed()), - new Promise((resolve) => { - const sub = ref.componentInstance.event.subscribe((event) => { - if (event?.reason !== 'done') return; - sub.unsubscribe(); - resolve(event); - }); - }), - ]); + const state = await deskModalResult(ref); if (state?.reason !== 'done') return; const { zone_id: selected_zone_id, ...desk_metadata } = state.metadata; const zone = desk.zone?.id || selected_zone_id || zone_id; @@ -552,32 +553,17 @@ export class DesksStateService extends AsyncHandler { return; } const use_assets = this._settings.get('app.desks.use_assets'); - let new_desk = new Desk({ - ...(use_assets ? desk : {}), - ...desk_metadata, - id: - (use_assets && desk['asset_type_id'] - ? desk.id - : desk_metadata.id) || - `desk-${zone.slice(-3)}.${randomInt(999_999)}`, - zone: this._org.levelWithID([zone]), - }); - // Only this desk's level is written. Read its stored list so desks - // that are not loaded in the current view are kept. - let original_desk_list: Desk[] = []; - try { - original_desk_list = use_assets - ? this.desks().filter((_) => (_.zone?.id || zone) === zone) - : await this._storedDeskList(zone); - } catch (e) { - notifyError(i18n('APP.CONCIERGE.DESKS_SAVE_ERROR', { error: e })); - ref.componentInstance.loading.set(false); - throw e; - } - const desk_list = [...original_desk_list]; - const idx = desk_list.findIndex((_) => _.id === desk.id); - if (idx >= 0) desk_list[idx] = new_desk; - else desk_list.push(new_desk); + const new_desk = this._editedDesk( + desk, + desk_metadata, + zone, + use_assets, + ); + const original_desk_list = await this._levelDeskList( + zone, + use_assets, + ).catch((e) => this._failDeskSave(ref, e)); + const desk_list = upsertDesk(original_desk_list, desk.id, new_desk); // Saving would store two desks with the same ID on this level. if (desk_list.filter((_) => _.id === new_desk.id).length > 1) { notifyError(`A desk with the ID "${new_desk.id}" already exists.`); @@ -585,111 +571,54 @@ export class DesksStateService extends AsyncHandler { ref.close(); return; } - if ( - new_desk.assigned_to && - (desk.assigned_to !== new_desk.assigned_to || - desk.id !== new_desk.id) - ) { - try { - await this._checkAssignedDeskLimit( - new_desk.assigned_to, - desk.id, - ); - } catch (error) { + if (new_desk.assigned_to && assignmentChanged(desk, new_desk)) { + await this._checkAssignedDeskLimit( + new_desk.assigned_to, + desk.id, + ).catch((error) => { notifyError( error instanceof Error ? error.message : `${error}`, ); ref.componentInstance.loading.set(false); ref.close(); throw error; - } - } - try { - if (use_assets) { - const saved = await saveDeskAsset( - new_desk, - zone, - this._deskAssetZones(zone), - ); - new_desk = deskFromAsset(saved, this._org.levelWithID([zone])); - } else { - await updateMetadata(zone, { - name: 'desks', - details: desk_list, - description: 'List of available desks', - }); - } - } catch (e) { - notifyError(i18n('APP.CONCIERGE.DESKS_SAVE_ERROR', { error: e })); - ref.componentInstance.loading.set(false); - throw e; + }); } + const saved_desk = await this._saveEditedDesk( + new_desk, + zone, + desk_list, + use_assets, + ).catch((e) => this._failDeskSave(ref, e)); + const rollback = () => + this._rollbackDeskSave(zone, original_desk_list, desk, saved_desk); let recreate = false; - if ( - desk.assigned_to && - (desk.assigned_to !== new_desk.assigned_to || - desk.id !== new_desk.id) - ) { - try { - await this._clearAssignedBooking(desk); - } catch (e) { - await this._rollbackDeskSave( - zone, - original_desk_list, - desk, - new_desk, - ); - notifyError( - i18n('APP.CONCIERGE.DESKS_SAVE_ERROR', { error: e }), - ); - ref.componentInstance.loading.set(false); - throw e; - } + if (desk.assigned_to && assignmentChanged(desk, saved_desk)) { + await this._clearAssignedBooking(desk).catch(async (e) => { + await rollback(); + return this._failDeskSave(ref, e); + }); recreate = true; } if ( - (desk.assigned_to !== new_desk.assigned_to || recreate) && - new_desk.assigned_to + saved_desk.assigned_to && + (recreate || desk.assigned_to !== saved_desk.assigned_to) ) { - const created = await saveBooking( - this._createAssignedBooking(new_desk, zone).toJSON(), - ).catch(async (e) => { - await this._rollbackDeskSave( - zone, - original_desk_list, - desk, - new_desk, - ); - if (recreate) { - await this._restoreAssignedBooking(desk, zone).catch( - (restore_err) => - console.error( - 'Failed to restore assigned booking during rollback', - restore_err, - ), - ); - } - if (e?.status === 409) { - notifyError( - i18n('APP.CONCIERGE.DESKS_ASSIGN_CONFLICT_ERROR'), - ); - } else { - notifyError( - i18n('APP.CONCIERGE.DESKS_SAVE_ERROR', { - error: e, - }), - ); - } - ref.componentInstance.loading.set(false); - throw e; - }); - // Cancel the assignee's overlapping ad-hoc desk bookings over the - // next 4 weeks so a later approval cannot reactivate them. - if (created?.id) { - await cancelOverlappingRecurringBookings(created, 'desk').catch( - () => [], - ); - } + await this._saveAssignedBooking( + desk, + saved_desk, + zone, + recreate, + rollback, + ).catch((e) => + this._failDeskSave( + ref, + e, + isConflict(e) + ? i18n('APP.CONCIERGE.DESKS_ASSIGN_CONFLICT_ERROR') + : undefined, + ), + ); } this._change.set(Date.now()); ref.close(); @@ -991,6 +920,116 @@ export class DesksStateService extends AsyncHandler { ); } + /** + * Build the desk to save from the modal values. Asset desks keep their + * stored fields and ID. New desks without an ID get a random one. + */ + private _editedDesk( + desk: Desk, + metadata: Partial, + zone: string, + use_assets: boolean, + ): Desk { + return new Desk({ + ...(use_assets ? desk : {}), + ...metadata, + id: + (use_assets && desk['asset_type_id'] ? desk.id : metadata.id) || + `desk-${zone.slice(-3)}.${randomInt(999_999)}`, + // Desks store the level as their zone. Desk types its zone as a + // PlaceZone, which BuildingLevel does not match. + zone: this._org.levelWithID([zone]) as unknown as PlaceZone, + }); + } + + /** + * Desks currently on a level. Only this level is written on save, so for + * metadata the stored list is read to keep desks that are not loaded in + * the current view. + */ + private async _levelDeskList( + zone: string, + use_assets: boolean, + ): Promise { + return use_assets + ? this.desks().filter((_) => (_.zone?.id || zone) === zone) + : this._storedDeskList(zone); + } + + /** + * Write an edited desk. Asset desks are saved one at a time. Metadata + * desks are saved as the level's full `desk_list`. + * @returns The desk as stored + */ + private async _saveEditedDesk( + desk: Desk, + zone: string, + desk_list: Desk[], + use_assets: boolean, + ): Promise { + if (use_assets) { + const saved = await saveDeskAsset( + desk, + zone, + this._deskAssetZones(zone), + ); + return deskFromAsset(saved, this._org.levelWithID([zone])); + } + await updateMetadata(zone, { + name: 'desks', + details: desk_list, + description: 'List of available desks', + }); + return desk; + } + + /** + * Create the assigned booking for a saved desk and cancel the assignee's + * overlapping desk bookings. On failure, roll back the desk save, restore + * the previous assigned booking when it was cleared, and rethrow. + */ + private async _saveAssignedBooking( + previous: Desk, + desk: Desk, + zone: string, + restore_previous: boolean, + rollback: () => Promise, + ) { + const created = await saveBooking( + this._createAssignedBooking(desk, zone).toJSON(), + ).catch(async (e) => { + await rollback(); + if (restore_previous) { + await this._restoreAssignedBooking(previous, zone).catch( + (restore_err) => + console.error( + 'Failed to restore assigned booking during rollback', + restore_err, + ), + ); + } + throw e; + }); + // Cancel the assignee's overlapping ad-hoc desk bookings over the + // next 4 weeks so a later approval cannot reactivate them. + if (created?.id) { + await cancelOverlappingRecurringBookings(created, 'desk').catch( + () => [], + ); + } + } + + /** Show a desk save error, stop the modal's loading state, and rethrow. */ + private _failDeskSave( + ref: MatDialogRef, + error: unknown, + message = i18n('APP.CONCIERGE.DESKS_SAVE_ERROR', { error }), + ): never { + notifyError(message); + ref.componentInstance.loading.set(false); + throw error; + } + private async _rollbackDeskSave( zone: string, original_desk_list: Desk[], diff --git a/apps/concierge/src/app/room-manager/room-management.service.ts b/apps/concierge/src/app/room-manager/room-management.service.ts index dd21cb21a6..6c5a76f349 100644 --- a/apps/concierge/src/app/room-manager/room-management.service.ts +++ b/apps/concierge/src/app/room-manager/room-management.service.ts @@ -6,7 +6,7 @@ import { resource, signal, } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { OrganisationService, SettingsService } from '@placeos/common'; import { PlaceSystem, querySystems, showMetadata } from '@placeos/ts-client'; import { queryAllPages } from '../query-all-pages'; @@ -123,20 +123,16 @@ export class RoomManagementService { } public editRoom(room: PlaceSystem = new PlaceSystem()) { - const ref = this._dialog.open(RoomModalComponent, { data: { room } }); - ref.afterClosed().subscribe((data) => { - if (data) setTimeout(() => this._change.update((c) => c + 1), 300); - }); + this._refreshAfterSave( + this._dialog.open(RoomModalComponent, { data: { room } }), + ); } public setRoomAlert(room: PlaceSystem) { if (!room) return; - const ref = this._dialog.open(RoomAlertModalComponent, { - data: { room }, - }); - ref.afterClosed().subscribe((data) => { - if (data) setTimeout(() => this._change.update((c) => c + 1), 300); - }); + this._refreshAfterSave( + this._dialog.open(RoomAlertModalComponent, { data: { room } }), + ); } public viewBookingHistory(room: PlaceSystem) { @@ -150,4 +146,11 @@ export class RoomManagementService { height: '100vh', }); } + + /** Load room alerts and rooms again after the dialog closes with a result. */ + private _refreshAfterSave(ref: MatDialogRef) { + ref.afterClosed().subscribe((data) => { + if (data) setTimeout(() => this._change.update((c) => c + 1), 300); + }); + } } diff --git a/libs/bookings/src/index.ts b/libs/bookings/src/index.ts index a1132078c5..7a1162695a 100644 --- a/libs/bookings/src/index.ts +++ b/libs/bookings/src/index.ts @@ -1,6 +1,8 @@ export * from './lib/booking-card.component'; export * from './lib/booking-details-modal.component'; +export * from './lib/booking-form.model'; export * from './lib/booking-form.service'; +export * from './lib/booking-form.types'; export * from './lib/booking.utilities'; export * from './lib/bookings.fn'; export * from './lib/desk-list-field.component'; diff --git a/libs/bookings/src/lib/booking-card.component.ts b/libs/bookings/src/lib/booking-card.component.ts index fc8eaec31e..5e9eb3f0c4 100644 --- a/libs/bookings/src/lib/booking-card.component.ts +++ b/libs/bookings/src/lib/booking-card.component.ts @@ -21,9 +21,21 @@ import { StatusPillComponent } from 'libs/components/src/lib/status-pill.compone import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { GroupEventDetailsModalComponent } from '../../../events/src/lib/group-event-details-modal.component'; import { BookingDetailsModalComponent } from './booking-details-modal.component'; -import { parkingRequestStatus } from './booking.utilities'; +import { + formatEmailName, + parkingRequestStatus, + visitorDisplayNameFor, +} from './booking.utilities'; import { ParkingService } from './parking.service'; +/** Icon and tooltip translation key for each booking type with its own icon */ +const TYPE_ICONS = new Map([ + ['desk', { icon: 'desk', tooltip: 'RESOURCE.DESK' }], + ['locker', { icon: 'lock', tooltip: 'RESOURCE.LOCKER' }], + ['parking', { icon: 'drive_eta', tooltip: 'RESOURCE.PARKING' }], + ['visitor', { icon: 'people', tooltip: 'RESOURCE.VISITOR' }], +]); + @Component({ selector: 'booking-card', template: ` @@ -87,47 +99,11 @@ import { ParkingService } from './parking.service'; class="divide-base-200-500 flex flex-col flex-wrap space-y-2 py-2 sm:flex-row sm:space-y-0 sm:divide-x" >
- @switch (type()) { - @case ('desk') { - desk - } - @case ('locker') { - lock - } - @case ('parking') { - drive_eta - } - @case ('visitor') { - people - } - @default { - book - } - } + {{ type_icon().icon }}
{{ resource_label() }}
@@ -155,39 +131,11 @@ import { ParkingService } from './parking.service'; {{ 'COMMON.CHECKED_IN' | translate }}
} - @if ( - !booking()?.checked_in && - !for_current_user() && - booking()?.booking_type !== 'group-event' - ) { -
- {{ 'BOOKINGS.ASSOCIATE' | translate }} -
- } - @if ( - !booking()?.checked_in && - booking()?.booking_type === 'group-event' - ) { + @for (badge of warning_badges(); track badge) {
- {{ 'RESOURCE.EVENT' | translate }} -
- } - @if ( - !booking()?.checked_in && is_reserved_parking_space() - ) { -
- {{ - (booking().status !== 'declined' - ? 'COMMON.STATUS_RESERVED' - : 'BOOKINGS.RELEASED' - ) | translate - }} + {{ badge | translate }}
} @@ -255,7 +203,7 @@ export class BookingCardComponent { if (!booking) return ''; return ( `${booking.user_name || ''}`.trim() || - this._formatEmailName(`${booking.user_email || ''}`.trim()) + formatEmailName(`${booking.user_email || ''}`.trim()) ); }); @@ -305,6 +253,32 @@ export class BookingCardComponent { public readonly type = computed(() => this.booking()?.type); + /** Icon and tooltip key for the booking type. Other types show a book + * icon with no tooltip. */ + public readonly type_icon = computed( + () => TYPE_ICONS.get(this.type()) ?? { icon: 'book', tooltip: '' }, + ); + + /** Translation keys of the warning badges shown until check-in */ + public readonly warning_badges = computed(() => { + const booking = this.booking(); + if (!booking || booking.checked_in) return []; + const is_group_event = booking.booking_type === 'group-event'; + const badges: string[] = []; + if (!this.for_current_user() && !is_group_event) { + badges.push('BOOKINGS.ASSOCIATE'); + } + if (is_group_event) badges.push('RESOURCE.EVENT'); + if (this.is_reserved_parking_space()) { + badges.push( + booking.status !== 'declined' + ? 'COMMON.STATUS_RESERVED' + : 'BOOKINGS.RELEASED', + ); + } + return badges; + }); + public readonly day = computed(() => { const date = this.booking()?.date || Date.now(); const is_today = isSameDay(Date.now(), date); @@ -348,7 +322,7 @@ export class BookingCardComponent { this.raw_description() || booking.asset_name || booking.asset_id ); } - return this._visitorDisplayNameFor(booking); + return visitorDisplayNameFor(booking); }); private _open_timer: ReturnType; @@ -394,54 +368,4 @@ export class BookingCardComponent { this._dialog.open(view_component, { data }); }, 300); } - - private _visitorDisplayNameFor(booking: Booking) { - const asset_id = `${booking?.asset_id || ''}`.trim(); - const group_member_name = this._visitorGroupMemberName(booking); - if (group_member_name) return group_member_name; - const attendee_name = this._visitorAttendeeName(booking); - if (attendee_name) return attendee_name; - const asset_name = - `${booking?.extension_data?.visitor_name || booking?.asset_name || ''}`.trim(); - const reason_values = [ - `${booking?.title || ''}`.trim().toLowerCase(), - `${booking?.description || ''}`.trim().toLowerCase(), - ].filter((_) => !!_); - if ( - asset_name && - asset_name.toLowerCase() !== asset_id.toLowerCase() && - !reason_values.includes(asset_name.toLowerCase()) - ) { - return asset_name; - } - return this._formatEmailName(asset_id || asset_name || 'Visitor'); - } - - private _visitorGroupMemberName(booking: Booking) { - const member = (booking.extension_data?.group_members || []).find( - (item) => item?.email === booking.asset_id, - ); - const name = `${member?.name || ''}`.trim(); - return name || ''; - } - - private _visitorAttendeeName(booking: Booking) { - const attendee = - (booking.attendees || []).find( - (item) => item?.email === booking.asset_id, - ) || booking.attendees?.[0]; - const name = `${attendee?.name || ''}`.trim(); - return name || ''; - } - - private _formatEmailName(value: string) { - if (!value.includes('@')) return value; - const [local_part] = value.split('@'); - const formatted_local = local_part - .replace(/[._-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!formatted_local) return value; - return formatted_local.replace(/\b\w/g, (char) => char.toUpperCase()); - } } diff --git a/libs/bookings/src/lib/booking-form.model.ts b/libs/bookings/src/lib/booking-form.model.ts new file mode 100644 index 0000000000..94ab8448ee --- /dev/null +++ b/libs/bookings/src/lib/booking-form.model.ts @@ -0,0 +1,351 @@ +import { + Injector, + signal, + untracked, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + email, + form, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import { + Booking, + BookingType, + current_user, + currentUser, + guardModelUndefinedWrites, + onFieldChange, + settingSignal, + setupFormTimeSync, + User, + type FormTimeSyncHandle, + type SignalFormRef, +} from '@placeos/common'; +import { addMinutes, isAfter } from 'date-fns'; + +/** Raw value held by the booking form model. */ +export interface BookingFormValue { + id: string; + parent_id: string; + event_id: string; + ical_uid: string; + date: number; + date_end: number; + all_day: boolean; + name: string; + duration: number; + booking_type: BookingType; + zones: string[]; + title: string; + description: string; + booking_asset: any; + resources: any[]; + company: string; + asset_id: string; + asset_name: string; + assets: any[]; + attendees: any[]; + map_id: string; + featured: boolean; + user: User; + user_id: string; + group: any; + user_email: string; + user_name: string; + timezone: string; + booked_by: User; + booked_by_id: string; + booked_by_email: string; + secondary_resource: any; + location: string; + attendance_type: string; + phone?: string; + permission: string; + images: any[]; + tags: string[]; + plate_number: string; + vehicle_type: string; + request_type: string; + requires_manual_approval: boolean; + space_restrictions: string | boolean; + extra_space_restrictions: any[]; + approver_group: string; + prefer_booked_location_first: boolean; + pass_number: string; + international: boolean; + recurrence_custom: boolean; + recurrence_type: string; + recurrence_days: any; + recurrence_nth_of_month: any; + recurrence_interval: any; + recurrence_end: any; + recurrence_instances: any; + notes: string; + attachments: string[]; + update_master: boolean; + self_registered: boolean; + is_assgined: boolean; +} + +export function bookingAttachments(booking: Booking = new Booking()): string[] { + booking = booking || new Booking(); + const extension_data = booking.extension_data || {}; + return [ + ...(extension_data.attachments || []), + ...(extension_data.p2_document_names || []), + ].filter((item) => !!item); +} + +/** Host (`user`) of a booking, falling back to the signed-in user for new + * bookings. Delegate bookings are made on behalf of someone else, so seeding + * this from the current user would silently reassign the host on save. */ +export function bookingHostUser(booking: Booking = new Booking()): User { + if (!booking?.user_email) return currentUser(); + return new User({ + id: booking.user_id || '', + email: booking.user_email, + name: booking.user_name || booking.user_email, + }); +} + +/** Build the raw booking form value from a booking. */ +export function bookingFormValue( + booking: Booking = new Booking(), +): BookingFormValue { + const extension_data = booking.extension_data || {}; + const visitor_name = + booking.booking_type === 'visitor' + ? extension_data.visitor_name || booking.asset_name || '' + : booking.asset_name || booking.description; + // Every field must be non-`undefined`: the signal-forms FieldTree only + // exposes a sub-field for keys whose value is defined, so an `undefined` + // seed would make `[formField]="form.x"` bind to nothing at runtime. + return { + id: booking.id || '', + parent_id: booking.parent_id || '', + event_id: booking.event_id || '', + ical_uid: extension_data.ical_uid || '', + date: booking.date ?? 0, + date_end: booking.date_end ?? 0, + all_day: booking.all_day ?? false, + name: extension_data.name || booking.asset_name || '', + duration: booking.duration ?? 0, + booking_type: booking.booking_type, + zones: booking.zones || [], + title: booking.title || '', + description: booking.description || '', + booking_asset: {}, + resources: [], + company: extension_data.company || '', + asset_id: booking.asset_id || '', + asset_name: visitor_name || '', + assets: extension_data.assets || [], + attendees: booking.attendees || [], + map_id: extension_data.map_id || '', + featured: extension_data.featured || false, + user: bookingHostUser(booking), + user_id: booking.user_id || '', + group: booking.group ?? {}, + user_email: booking.user_email || '', + user_name: booking.user_name || '', + timezone: booking.timezone || '', + booked_by: currentUser(), + booked_by_id: booking.booked_by_id || '', + booked_by_email: booking.booked_by_email || '', + secondary_resource: + extension_data.other_asset_type || + extension_data.secondary_resource || + {}, + location: extension_data.location || '', + attendance_type: extension_data.attendance_type || 'ANY', + phone: extension_data.phone || '', + permission: booking.permission || 'PRIVATE', + images: booking.images || [], + tags: booking?.tags || [], + plate_number: extension_data.plate_number || '', + vehicle_type: extension_data.vehicle_type || 'car', + request_type: extension_data.request_type || 'standard', + requires_manual_approval: + extension_data.requires_manual_approval ?? false, + space_restrictions: extension_data.space_restrictions ?? false, + extra_space_restrictions: extension_data.extra_space_restrictions ?? [], + approver_group: extension_data.approver_group || '', + prefer_booked_location_first: + extension_data.prefer_booked_location_first ?? false, + pass_number: extension_data.pass_number || '', + international: extension_data.international ?? false, + recurrence_custom: extension_data.recurrence_custom ?? false, + recurrence_type: booking.recurrence_type || 'none', + recurrence_days: booking.recurrence_days ?? 0, + recurrence_nth_of_month: booking.recurrence_nth_of_month ?? 0, + recurrence_interval: booking.recurrence_interval ?? 0, + recurrence_end: booking.recurrence_end ?? 0, + recurrence_instances: extension_data.recurrence_instances ?? 0, + notes: extension_data.notes || '', + attachments: bookingAttachments(booking), + update_master: false, + self_registered: false, + is_assgined: false, + } as BookingFormValue; +} + +/** Copy the selected resource onto the booking asset fields of the form model. */ +function setBookingAsset( + model: WritableSignal, + resource: any, +) { + if (!resource) { + // Use '' rather than `undefined`: an undefined value removes the + // `asset_id` sub-field from the signal-forms FieldTree, breaking any + // `[formField]="form.asset_id"` binding (`this.field() is not a function`). + model.update((m) => ({ ...m, asset_id: '' })); + return; + } + model.update((m) => ({ + ...m, + asset_id: resource.id, + asset_name: resource.name, + name: resource.display_name || resource.name || resource.id, + map_id: resource.map_id || resource.id, + description: resource.name, + zones: resource.zone + ? [resource.zone?.parent_id, resource.zone?.id] + : [], + booking_asset: resource, + })); +} + +/** FieldTree for the booking form, with attached time-sync handle. */ +export type BookingForm = FieldTree & { + _time_sync?: FormTimeSyncHandle; +}; + +export type BookingFormRef = SignalFormRef & { + /** Time-sync handle for runtime duration/bookable-hours reconfiguration. */ + time_sync: FormTimeSyncHandle; +}; + +export function generateBookingForm( + booking: Booking = new Booking(), + injector?: Injector, +): BookingFormRef { + const started = booking.state === 'started'; + const model: WritableSignal = signal( + bookingFormValue(booking), + ); + + // Keep every key defined so signal-forms never drops a sub-field bound via + // `[formField]` (an undefined value triggers `this.field() is not a + // function`). Guards writes synchronously — no reactive surface. + guardModelUndefinedWrites(model, bookingFormValue(new Booking())); + + const require_plate_number = settingSignal( + 'parking.require_plate_number', + false, + ); + const require_space_restriction = settingSignal( + 'parking.require_space_restriction', + false, + ); + const booking_form = form( + model, + (p) => { + required(p.date); + required(p.asset_id); + // Visitor bookings use asset_id to hold the visitor's email address. + email(p.asset_id, { + when: ({ valueOf }) => valueOf(p.booking_type) === 'visitor', + }); + // Parking requests can require a vehicle plate number via settings. + // Scope to parking bookings so the required state never bleeds into + // desk/visitor forms sharing this FieldTree. + required(p.plate_number, { + when: ({ valueOf }) => + valueOf(p.booking_type) === 'parking' && + require_plate_number(), + }); + validate(p.plate_number, ({ value, valueOf }) => + valueOf(p.booking_type) === 'parking' && + require_plate_number() && + !`${value() || ''}`.trim() + ? { kind: 'required' } + : undefined, + ); + validate(p.space_restrictions, ({ value, valueOf }) => + valueOf(p.booking_type) === 'parking' && + require_space_restriction() && + !value() + ? { kind: 'required' } + : undefined, + ); + validate(p.duration, ({ value, valueOf }) => { + const date = valueOf(p.date); + if (value() <= 0) return { kind: 'duration' }; + return date && isAfter(Date.now(), addMinutes(date, value())) + ? { kind: 'duration' } + : undefined; + }); + // Depend only on the date field's own value (`ctx.value()`) and read + // `id` untracked. `valueOf(p.id)` here threw NG01901 (the resolver + // can't navigate `.id` from the `date` field), and reading the whole + // `model()` made this recompute on every model change — extra reactive + // surface that could feed loops on the explore page. + disabled(p.date, ({ value }) => { + if (started) return true; + return value() < Date.now() && !!untracked(model).id; + }); + }, + { injector }, + ) as BookingForm; + + // user → user_id/email/name + onFieldChange( + model, + (v) => v.user, + (user) => { + if (!user) return; + // Coalesce to '' so the sub-fields are never removed from the + // FieldTree (an undefined value breaks `[formField]="form.user_*"`). + model.update((m) => ({ + ...m, + user, + user_id: (user as any)?.id ?? '', + user_email: (user as any)?.email ?? '', + user_name: (user as any)?.name ?? '', + })); + }, + injector, + ); + // resources → booking asset fields + onFieldChange( + model, + (v) => v.resources, + (resources) => { + // Visitor bookings store the visitor email in `asset_id`; they do + // not select a booking resource. Form resets replace `resources` + // with a new empty array, which must not clear that email on edit. + if (untracked(model).booking_type === 'visitor') return; + setBookingAsset(model, (resources || [])[0]); + }, + injector, + ); + // Keep booked_by synced to the current user + current_user.subscribe((user) => { + if (!user) return; + model.update((m) => ({ + ...m, + booked_by: user, + booked_by_id: user?.id, + booked_by_email: user?.email, + })); + }); + + const time_sync = setupFormTimeSync(model, {}, injector); + (booking_form as BookingForm)._time_sync = time_sync; + (model as any)._time_sync = time_sync; + return { model, form: booking_form, time_sync }; +} diff --git a/libs/bookings/src/lib/booking-form.payload.ts b/libs/bookings/src/lib/booking-form.payload.ts new file mode 100644 index 0000000000..76fcdfd20d --- /dev/null +++ b/libs/bookings/src/lib/booking-form.payload.ts @@ -0,0 +1,118 @@ +import { Booking, currentUser } from '@placeos/common'; +import { bookingFormValue } from './booking-form.model'; + +/** Keys that live on the `Booking` model itself. `extension_data` must never + * duplicate these — built once from a throwaway instance. */ +const BOOKING_MODEL_KEYS = new Set(Object.keys(new Booking())); +const BOOKING_FORM_KEYS = new Set(Object.keys(bookingFormValue(new Booking()))); +const BOOKING_EXTENSION_FIELD_BLACKLIST = new Set([ + 'resources', + 'assets', + 'level', +]); + +/** Keep only real form fields from carried `extension_data`. */ +export function formExtensionData(data: Record = {}) { + const extra: Record = {}; + for (const key in data) { + if ( + BOOKING_FORM_KEYS.has(key) && + !BOOKING_MODEL_KEYS.has(key) && + !BOOKING_EXTENSION_FIELD_BLACKLIST.has(key) + ) { + extra[key] = data[key]; + } + } + return extra; +} + +/** Keep only `Booking` model and form fields from a form value for saving. */ +export function formBookingData(value: Record) { + const data: Record = {}; + for (const key in value) { + if (key === 'extension_data') { + data.extension_data = formExtensionData(value.extension_data); + } else if ( + // `asset_ids` is spread into the form model from the booking being + // edited and never updated when `asset_id` changes, so sending it + // back would overwrite the new resource with the old one. The + // `Booking` constructor rebuilds it from `asset_id`. + key !== 'asset_ids' && + !BOOKING_EXTENSION_FIELD_BLACKLIST.has(key) && + (BOOKING_FORM_KEYS.has(key) || BOOKING_MODEL_KEYS.has(key)) + ) { + data[key] = value[key]; + } + } + return data; +} + +/** Build the `extension_data` payload saved with a booking. Only fields that + * need renaming, coercion, computing or a fallback live here — plain flat form + * fields (e.g. `phone`, `company`, `recurrence_instances`, `plate_number`, + * `notes`) are copied into `extension_data` automatically by the `Booking` + * constructor, so they must NOT be duplicated below. */ +export function buildBookingExtensionData( + value: Record, + group_members: any[], +) { + const type = value.booking_type; + return { + ...formExtensionData(value.extension_data), + ...(value.extension_data?.invoice + ? { + invoice: value.extension_data.invoice, + invoice_id: value.extension_data.invoice_id, + } + : {}), + // `group` is a getter on `Booking`, so the constructor skips the + // top-level form value — it has to be set into `extension_data` here. + group: value.group, + // `assets` is ignored by the constructor's auto-copy, so map it here. + assets: value.assets.map((_: any) => _.toJSON()), + ...(type === 'desk' + ? { + assigned_asset_id: value.asset_id, + assigned_asset_name: value.asset_name || value.asset_id, + } + : {}), + ...(type === 'visitor' + ? { + international: !!value.international, + visitor_name: value.asset_name || value.asset_id || '', + } + : {}), + ...(type === 'parking' + ? { + requires_manual_approval: !!value.requires_manual_approval, + user_groups: [ + ...(value.user && + value.user.email !== currentUser()?.email + ? value.user.groups || [] + : currentUser()?.groups || []), + ], + } + : {}), + ...(group_members.length ? { group_members } : {}), + department: value.user?.department || currentUser()?.department, + }; +} + +/** Query for `saveBooking`. Links the booking to its parent event or booking, + * and targets one occurrence when an instance of a series is edited alone. */ +export function bookingSaveQuery( + value: Record, + booking: Booking, +): Record { + const { event_id, parent_id } = value; + const q: Record = event_id + ? { ical_uid: value.ical_uid, event_id: event_id } + : parent_id + ? { booking_id: parent_id } + : {}; + if (booking.instance && !value.update_master) { + q.instance = true; + q.start_time = booking.booking_start; + } + return q; +} diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 1022c8a660..296c1fd2f8 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -13,11 +13,6 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Event, NavigationEnd, Router } from '@angular/router'; -import { - deskFromAsset, - queryDeskAssetsForZones, - queryParkingSpacesForZones, -} from '@placeos/assets'; import { AsyncHandler, Booking, @@ -28,10 +23,8 @@ import { currentUserCanApprove, currentUserIsLoaded, currentUserLoaded, - Desk, errorMessage, firstValueWhere, - flatten, getAllDayTimeRange, getInvalidSignalFields, GuestUser, @@ -41,7 +34,6 @@ import { notifyError, notifyWarn, OrganisationService, - randomString, rulesForResource, SettingsService, unique, @@ -51,7 +43,6 @@ import { import { cleanObject, isMock, - listChildMetadata, PlaceZone, showMetadata, showUser, @@ -60,16 +51,50 @@ import { addDays, addMinutes, endOfDay, format, getUnixTime } from 'date-fns'; import { openRecurringClashModal } from 'libs/components/src/lib/recurring-clash-modal.component'; import { CalendarService } from 'libs/events/src/lib/calendar.service'; import { removeEventGuest } from 'libs/events/src/lib/events.fn'; -import { BookingLinkModalComponent } from './booking-link-modal.component'; import { bookingAttachments, bookingFormValue, type BookingFormValue, bookingHostUser, - findNearbyFeature, generateBookingForm, - loadLockerResources, -} from './booking.utilities'; +} from './booking-form.model'; +import { + bookingSaveQuery, + buildBookingExtensionData, + formBookingData, + formExtensionData, +} from './booking-form.payload'; +import type { + BookingAsset, + BookingFlowOptions, + BookingFlowView, + GroupBookingFailure, +} from './booking-form.types'; +import { + groupName, + mapGroupMembers, + mapGroupMembersFromBookings, + mapGroupMembersFromExtension, + matchGroupSiblings, + rollbackGroupBookings, + visitorMemberPatch, +} from './booking-group.utilities'; +import { BookingLinkModalComponent } from './booking-link-modal.component'; +import { + hasAssignedDesk, + loadDeskResources, + loadMetadataResources, + loadParkingResources, +} from './booking-resource.loaders'; +import { + findResourceById, + groupAvailability, + nearbyResources, + pickAutoAllocatedResource, + reserveResource, + resourceReserved, +} from './booking-resource.utilities'; +import { loadLockerResources } from './booking.utilities'; import { bookedResourceList, findBookingClashes, @@ -85,8 +110,6 @@ import { validateAssetRequestsForResource } from 'libs/assets/src/lib/assets.fn' import { openConfirmModal } from 'libs/components/src/lib/confirm-modal.component'; import { PaymentsService } from 'libs/payments/src/lib/payments.service'; -export type BookingFlowView = 'form' | 'map' | 'confirm' | 'success'; - const BOOKING_TYPES = ['desk', 'parking', 'locker', 'catering']; const STORAGE_KEYS = { @@ -98,29 +121,6 @@ const STORAGE_KEYS = { last_group_booking_errors: 'PLACEOS.last_group_booking_errors', } as const; -export interface BookingFlowOptions { - /** Type of booking being made */ - type: BookingType; - /** Zone to check available */ - zone_id?: string; - /** List of features that the asset should associate */ - features?: string[]; - /** Whether booking is for a group */ - group?: boolean; - /** Recurrence Pattern */ - pattern?: 'none' | 'daily' | 'weekly' | 'monthly'; - /** Recurrence ending */ - recurr_end?: number; - /** List of group members to book for */ - members?: User[]; - /** Whether to only show favourite rooms */ - show_fav?: boolean; - /** Whether to group bookings */ - disable_date?: boolean; - /** Whether resource has accessibility options */ - show_accessible?: boolean; -} - /** Whether a resource has finished loading for its current params */ function resourceSettled(ref: { status: Signal }) { const status = ref.status(); @@ -155,79 +155,11 @@ function assetWindowKey(date: unknown, duration: unknown) { : ''; } -export interface BookingAsset { - id: string; - map_id?: string; - display_name?: string; - name: string; - bookable: boolean; - zone?: PlaceZone; - level?: PlaceZone; - location?: string; - images?: string[]; - groups?: string[]; - assigned_to?: string; - features: string[]; -} - type GroupContainerForm = Partial & { user?: User; extension_data?: Record; }; -export interface GroupBookingFailure { - email: string; - name: string; - asset_id?: string; - asset_name?: string; - error: string; -} - -/** Keys that live on the `Booking` model itself. `extension_data` must never - * duplicate these — built once from a throwaway instance. */ -const BOOKING_MODEL_KEYS = new Set(Object.keys(new Booking())); -const BOOKING_FORM_KEYS = new Set(Object.keys(bookingFormValue(new Booking()))); -const BOOKING_EXTENSION_FIELD_BLACKLIST = new Set([ - 'resources', - 'assets', - 'level', -]); - -/** Keep only real form fields from carried `extension_data`. */ -function formExtensionData(data: Record = {}) { - const extra: Record = {}; - for (const key in data) { - if ( - BOOKING_FORM_KEYS.has(key) && - !BOOKING_MODEL_KEYS.has(key) && - !BOOKING_EXTENSION_FIELD_BLACKLIST.has(key) - ) { - extra[key] = data[key]; - } - } - return extra; -} - -function formBookingData(value: Record) { - const data: Record = {}; - for (const key in value) { - if (key === 'extension_data') { - data.extension_data = formExtensionData(value.extension_data); - } else if ( - // `asset_ids` is spread into the form model from the booking being - // edited and never updated when `asset_id` changes, so sending it - // back would overwrite the new resource with the old one. The - // `Booking` constructor rebuilds it from `asset_id`. - key !== 'asset_ids' && - !BOOKING_EXTENSION_FIELD_BLACKLIST.has(key) && - (BOOKING_FORM_KEYS.has(key) || BOOKING_MODEL_KEYS.has(key)) - ) { - data[key] = value[key]; - } - } - return data; -} - /** A booking type, or `undefined` for the blank `' '` placeholder that * `new Booking()` sets when no type is given. */ function knownBookingType( @@ -243,57 +175,6 @@ function isCrossTypeEdit(booking: Booking, type: BookingType) { return !!booking?.id && !!booking_type && booking_type !== type; } -/** Build the `extension_data` payload saved with a booking. Only fields that - * need renaming, coercion, computing or a fallback live here — plain flat form - * fields (e.g. `phone`, `company`, `recurrence_instances`, `plate_number`, - * `notes`) are copied into `extension_data` automatically by the `Booking` - * constructor, so they must NOT be duplicated below. */ -function buildBookingExtensionData( - value: Record, - group_members: any[], -) { - const type = value.booking_type; - return { - ...formExtensionData(value.extension_data), - ...(value.extension_data?.invoice - ? { - invoice: value.extension_data.invoice, - invoice_id: value.extension_data.invoice_id, - } - : {}), - // `group` is a getter on `Booking`, so the constructor skips the - // top-level form value — it has to be set into `extension_data` here. - group: value.group, - // `assets` is ignored by the constructor's auto-copy, so map it here. - assets: value.assets.map((_: any) => _.toJSON()), - ...(type === 'desk' - ? { - assigned_asset_id: value.asset_id, - assigned_asset_name: value.asset_name || value.asset_id, - } - : {}), - ...(type === 'visitor' - ? { - international: !!value.international, - visitor_name: value.asset_name || value.asset_id || '', - } - : {}), - ...(type === 'parking' - ? { - requires_manual_approval: !!value.requires_manual_approval, - user_groups: [ - ...(value.user && - value.user.email !== currentUser()?.email - ? value.user.groups || [] - : currentUser()?.groups || []), - ], - } - : {}), - ...(group_members.length ? { group_members } : {}), - department: value.user?.department || currentUser()?.department, - }; -} - @Injectable({ providedIn: 'root', }) @@ -584,31 +465,10 @@ export class BookingFormService extends AsyncHandler { /** Signal grouping available resources for group bookings */ public readonly grouped_availability = computed(() => { const options = this._options(); - const resource = this.available_resources(); - const groups: BookingAsset[][] = []; - const asset_list = [...resource].sort((a, b) => - a.zone?.id?.localeCompare(b.zone?.id), - ); const members = options.members?.length ? options.members : [currentUser()]; - while (asset_list.length) { - const group: BookingAsset[] = []; - let asset = asset_list.pop(); - while (group.length < members.length) { - if ( - group.length && - !group.find((_) => _.zone?.id === asset.zone?.id) - ) { - break; - } - group.push(asset); - asset = asset_list.pop(); - } - if (group.length < members.length) continue; - groups.push(group); - } - return groups; + return groupAvailability(this.available_resources(), members.length); }); public get booking() { @@ -704,41 +564,13 @@ export class BookingFormService extends AsyncHandler { }); } - private async _computeHasAssignedDesk( + private _computeHasAssignedDesk( user_email = currentUser()?.email, ): Promise { - const buildings = this._org.building_list(); - if (!(buildings?.length > 0)) return false; - const email = user_email?.toLowerCase(); - if (!email) return false; - if (this._settings.get('app.desks.use_assets')) { - const building_ids = new Set( - buildings.map((building) => building.id), - ); - const level_ids = this._org.levels - .filter((level) => building_ids.has(level.parent_id)) - .map((level) => level.id); - const desks = await queryDeskAssetsForZones(level_ids).catch( - () => [], - ); - return desks.some( - (desk) => desk.assigned_to?.toLowerCase() === email, - ); - } - const map_metadata = (meta) => - (meta?.metadata?.desks?.details instanceof Array - ? meta.metadata.desks.details - : [] - ).map((desk) => new Desk({ ...desk, zone: meta.zone })); - const desk_lists = await Promise.all( - buildings.map((building) => - listChildMetadata(building.id, { name: 'desks' }) - .then((data) => flatten(data.map(map_metadata))) - .catch(() => [] as Desk[]), - ), - ); - return flatten(desk_lists).some( - (desk) => desk.assigned_to?.toLowerCase() === email, + return hasAssignedDesk( + this._org, + user_email, + this._settings.get('app.desks.use_assets'), ); } @@ -1318,36 +1150,7 @@ export class BookingFormService extends AsyncHandler { public async postForm(ignore_check = false, reset_form = true) { if (!this.form) throw 'No form for booking'; - // user/booked_by may have been seeded with the placeholder EMPTY_USER - // before the signed-in user loaded. Refresh them from the now-loaded - // current user so bookings are never saved against the empty user. - if (isEmptyUser(this.model().user as any)) { - this._patch({ user: currentUser(), booked_by: currentUser() }); - } - // For all-day bookings the date/duration window is derived from the - // all-day period at post time. Apply that clamped window up-front so the - // form's duration validator sees the real (valid) window rather than a - // stale duration left over from before the all-day toggle. The time-sync - // effect does this asynchronously; doing it here keeps posting correct - // even when the reactive flush has not yet run. - if (this.model().all_day && this.model().date) { - const { date, duration, date_end } = this._allDayTimeRange( - this.model().date, - ); - this._patch({ date, duration, date_end }); - } - // Before validation, so type-dependent validators see the real type. - this._patch({ booking_type: this._formBookingType() }); - if (!this.form().valid()) { - const invalid_fields = getInvalidSignalFields( - this.form, - this.model, - this._invalid_field_mappings(), - ); - throw i18n('FORM.INVALID_FIELDS', { - field_list: invalid_fields.join(', '), - }); - } + this._prepareFormForPost(); const value = this.model() as any; const effective_timezone = this.timezone || value.timezone; const booking = this._booking() || new Booking(); @@ -1370,53 +1173,13 @@ export class BookingFormService extends AsyncHandler { } const host = value.user?.email || value.user_email || currentUser()?.email; - if (ignore_check) { - await this._checkAssignedResourceRestriction( - host, - this._options().type, - ); - } else { - await this._checkResourceAvailable( - { - ...booking, - ...value, - user_email: host, - }, - this._options().type, - ); - await this._checkResourceRules( - value.resources, - all_day_period.date, - all_day_period.duration, - host, - ); - await this._checkRecurringClashes( - { - ...booking, - ...value, - date: all_day_period.date, - duration: all_day_period.duration, - date_end: all_day_period.date_end, - user_email: host, - timezone: effective_timezone, - }, - this._options().type, - ); - } - if (this._payments.enabled) { - const receipt = await this._payments.makePayment({ - type: this._options().type, - resource_name: value.asset_name, - date: value.date, - duration: value.duration, - all_day: value.all_day, - }); - if (!receipt?.success) return; - (value as any).extension_data = { - invoice: receipt, - invoice_id: receipt.invoice_id, - }; - } + await this._checkBookingAllowed( + { ...booking, ...value, user_email: host }, + all_day_period, + effective_timezone, + ignore_check, + ); + if (!(await this._takePayment(value))) return; const selected_zones = [ ...(value?.zones || []), ...(value.booking_asset?.zones || []), @@ -1434,57 +1197,16 @@ export class BookingFormService extends AsyncHandler { value.duration = all_day_period.duration; value.date_end = all_day_period.date_end; } - const { event_id, parent_id } = value; + const q = bookingSaveQuery(value, booking); delete value.event_id; - const resources = value.resources || []; - const zone = - this._org.levelWithID(resources[0]?.zone_id) || resources[0]?.zone; - const zones = - zone && zone instanceof Object - ? unique([ - this._org.organisation.id, - this._org.region?.id, - zone.parent_id, - zone.id, - ]) - : [ - this._org.organisation.id, - this._org.region?.id, - this._org.building?.id, - ]; - const q: Record = event_id - ? { ical_uid: value.ical_uid, event_id: event_id } - : parent_id - ? { booking_id: parent_id } - : {}; - if (booking.instance && !value.update_master) { - q.instance = true; - q.start_time = booking.booking_start; - } - if (value.recurrence_type && value.recurrence_type !== 'none') { - const available_period = getUnixTime( - endOfDay( - addDays( - Date.now(), - this._settings.get( - `app.${value.booking_type}s.available_period`, - ) || 90, - ), - ), - ); - if ( - !value.recurrence_end || - value.recurrence_end > available_period - ) { - value.recurrence_end = available_period; - } - } + const zones = unique([ + ...this._saveZones(value.resources || []), + ...(value.zones || []), + ]).filter((_) => _); + this._clampRecurrenceEnd(value); const group_members = this._options().group && this._options().members?.length - ? this.mapGroupMembers( - value.booking_type, - this._options().members, - ) + ? mapGroupMembers(value.booking_type, this._options().members) : []; const result = await saveBooking( new Booking({ @@ -1501,64 +1223,12 @@ export class BookingFormService extends AsyncHandler { approved: this._settings.get('app.bookings.no_approval') === true && currentUserCanApprove(), - zones: unique([...zones, ...(value.zones || [])]).filter( - (_) => _, - ), + zones, }).toJSON(), q, - ).catch(async (e) => { - this._loading.set(''); - let error = e?.error || e; - if (error instanceof Response) { - error = await error - .clone() - .json() - .catch(() => null); - } - const failure = e?.status - ? { - message: this._error_message(error), - status: e.status, - } - : error; - if (this._isPermissionError(failure)) this._clearSavedHostChange(); - throw failure; - }); + ).catch((e) => this._saveFailure(e)); if (value.assets?.length || booking.extension_data.assets?.length) { - // The booking record exists by this point, so a failure here must - // remove it again. Otherwise the user is told the booking failed - // while the record stays visible in concierge. - const is_new_booking = !booking.id && !value.id; - try { - const requests = await validateAssetRequestsForResource( - { ...result, from_booking: true }, - { - date: value.date, - duration: value.duration, - all_day: value.all_day, - host: value.booked_by_email, - zones: unique([ - ...zones, - ...(value.zones || []), - ]).filter((_) => _), - }, - value.assets, - ); - if (!requests) throw i18n('BOOKINGS.ASSETS_INVALID_ERROR'); - await requests(); - } catch (e) { - console.error("Couldn't update asset requests", e); - this._loading.set(''); - if (is_new_booking && result?.id) { - await removeBooking(result.id).catch((err) => - console.error('Failed to rollback booking', err), - ); - } - throw e?.status === 409 - ? i18n('BOOKINGS.ASSETS_CLASH_ERROR') - : errorMessage(e?.error || e) || - i18n('BOOKINGS.ASSETS_INVALID_ERROR'); - } + await this._saveAssetRequests(result, value, booking, zones); } this._loading.set(''); const { booking_type } = value; @@ -1575,6 +1245,195 @@ export class BookingFormService extends AsyncHandler { return result; } + /** + * Bring the form up to date for posting, then throw a list of the + * invalid fields when the form is not valid. + */ + private _prepareFormForPost() { + // user/booked_by may have been seeded with the placeholder EMPTY_USER + // before the signed-in user loaded. Refresh them from the now-loaded + // current user so bookings are never saved against the empty user. + if (isEmptyUser(this.model().user as any)) { + this._patch({ user: currentUser(), booked_by: currentUser() }); + } + // For all-day bookings the date/duration window is derived from the + // all-day period at post time. Apply that clamped window up-front so the + // form's duration validator sees the real (valid) window rather than a + // stale duration left over from before the all-day toggle. The time-sync + // effect does this asynchronously; doing it here keeps posting correct + // even when the reactive flush has not yet run. + if (this.model().all_day && this.model().date) { + const { date, duration, date_end } = this._allDayTimeRange( + this.model().date, + ); + this._patch({ date, duration, date_end }); + } + // Before validation, so type-dependent validators see the real type. + this._patch({ booking_type: this._formBookingType() }); + if (!this.form().valid()) { + const invalid_fields = getInvalidSignalFields( + this.form, + this.model, + this._invalid_field_mappings(), + ); + throw i18n('FORM.INVALID_FIELDS', { + field_list: invalid_fields.join(', '), + }); + } + } + + /** + * Check that the booking can be saved: resource availability, booking + * rules and recurring clashes. With `ignore_check` only the + * assigned-resource restriction applies. + */ + private async _checkBookingAllowed( + request: Record, + period: { date: number; duration: number; date_end: number }, + timezone: string, + ignore_check: boolean, + ) { + if (ignore_check) { + await this._checkAssignedResourceRestriction( + request.user_email, + this._options().type, + ); + return; + } + await this._checkResourceAvailable(request, this._options().type); + await this._checkResourceRules( + request.resources, + period.date, + period.duration, + request.user_email, + ); + await this._checkRecurringClashes( + { ...request, ...period, timezone }, + this._options().type, + ); + } + + /** + * Take payment for the booking when payments are enabled and store the + * receipt on `value`. Resolves `false` when the payment does not succeed. + */ + private async _takePayment(value: Record) { + if (!this._payments.enabled) return true; + const receipt = await this._payments.makePayment({ + type: this._options().type, + resource_name: value.asset_name, + date: value.date, + duration: value.duration, + all_day: value.all_day, + }); + if (!receipt?.success) return false; + value.extension_data = { + invoice: receipt, + invoice_id: receipt.invoice_id, + }; + return true; + } + + /** Org, region, building and level zones of the first selected resource. + * Uses the active building when the resource has no known level. */ + private _saveZones(resources: any[]) { + const zone = + this._org.levelWithID(resources[0]?.zone_id) || resources[0]?.zone; + return zone && zone instanceof Object + ? unique([ + this._org.organisation.id, + this._org.region?.id, + zone.parent_id, + zone.id, + ]) + : [ + this._org.organisation.id, + this._org.region?.id, + this._org.building?.id, + ]; + } + + /** Limit the end of a recurring booking to the type's available period. */ + private _clampRecurrenceEnd(value: Record) { + if (!value.recurrence_type || value.recurrence_type === 'none') return; + const available_period = getUnixTime( + endOfDay( + addDays( + Date.now(), + this._settings.get( + `app.${value.booking_type}s.available_period`, + ) || 90, + ), + ), + ); + if (!value.recurrence_end || value.recurrence_end > available_period) { + value.recurrence_end = available_period; + } + } + + /** Convert a failed save into the error that `postForm` throws. A + * permission failure also reverts the saved host change. */ + private async _saveFailure(e: any): Promise { + this._loading.set(''); + let error = e?.error || e; + if (error instanceof Response) { + error = await error + .clone() + .json() + .catch(() => null); + } + const failure = e?.status + ? { + message: this._error_message(error), + status: e.status, + } + : error; + if (this._isPermissionError(failure)) this._clearSavedHostChange(); + throw failure; + } + + /** + * Save the asset requests of a saved booking. The booking record exists + * by this point, so a new booking is removed again when this fails. + * Otherwise the user is told the booking failed while the record stays + * visible in concierge. + */ + private async _saveAssetRequests( + result: Booking, + value: Record, + booking: Booking, + zones: string[], + ) { + const is_new_booking = !booking.id && !value.id; + try { + const requests = await validateAssetRequestsForResource( + { ...result, from_booking: true }, + { + date: value.date, + duration: value.duration, + all_day: value.all_day, + host: value.booked_by_email, + zones, + }, + value.assets, + ); + if (!requests) throw i18n('BOOKINGS.ASSETS_INVALID_ERROR'); + await requests(); + } catch (e) { + console.error("Couldn't update asset requests", e); + this._loading.set(''); + if (is_new_booking && result?.id) { + await removeBooking(result.id).catch((err) => + console.error('Failed to rollback booking', err), + ); + } + throw e?.status === 409 + ? i18n('BOOKINGS.ASSETS_CLASH_ERROR') + : errorMessage(e?.error || e) || + i18n('BOOKINGS.ASSETS_INVALID_ERROR'); + } + } + public setting(key: string) { return this.settingForType(this._options().type, key); } @@ -1607,42 +1466,14 @@ export class BookingFormService extends AsyncHandler { if (!available?.length) { throw i18n('BOOKINGS.DESK_AVAILABLE_ERROR'); } - // Group available desks by zone (level) id - const zone_map: Record = {}; - for (const asset of available) { - const zone_id = asset.zone?.id || 'unknown'; - if (!zone_map[zone_id]) zone_map[zone_id] = []; - zone_map[zone_id].push(asset); - } - // Find the level with the most free desks - let best_zone_id = ''; - let best_count = 0; - for (const zone_id in zone_map) { - if (zone_map[zone_id].length > best_count) { - best_count = zone_map[zone_id].length; - best_zone_id = zone_id; - } - } - const candidates = zone_map[best_zone_id]; - // Select a random desk from that level - const selected = - candidates[Math.floor(Math.random() * candidates.length)]; - const zone = selected.zone; + const selected = pickAutoAllocatedResource(available); this._patch({ resources: [selected], asset_id: selected.id, asset_name: selected.name || selected.id, map_id: selected.map_id || selected.id, booking_asset: selected, - zones: (zone - ? unique([ - this._org.organisation.id, - this._org.region?.id, - zone.parent_id, - zone.id, - ]) - : [this._org.organisation.id, this._org.region?.id] - ).filter((_) => _), + zones: this._assetZones(selected.zone), }); } @@ -1709,7 +1540,7 @@ export class BookingFormService extends AsyncHandler { }), ); const unavailable = group_members.filter((_, idx) => !available[idx]); - const group_name = this._groupName(); + const group_name = groupName(); const group_error = i18n('BOOKINGS.GROUP_SOME_HAVE_BOOKINGS', { members: unavailable.map((_) => _.name || _.email)?.join(', '), }); @@ -1770,7 +1601,7 @@ export class BookingFormService extends AsyncHandler { ? unavailable_errors.join('\n') : group_error; if (rollback_on_group_error) { - await this.rollbackGroupBookings(booking_ids); + await rollbackGroupBookings(booking_ids); throw unavailable_error; } notifyWarn(unavailable_error); @@ -1780,7 +1611,7 @@ export class BookingFormService extends AsyncHandler { } } catch (error) { if (rollback_on_group_error && booking_ids.length) { - await this.rollbackGroupBookings(booking_ids); + await rollbackGroupBookings(booking_ids); } throw this._error_message(error); } @@ -1814,7 +1645,7 @@ export class BookingFormService extends AsyncHandler { const rollback_on_group_error = this.setting('rollback_group_bookings') === true; const form = this.model() as any; - const group_name = this._groupName(); + const group_name = groupName(); const booking_ids: string[] = []; let parent_id = ''; let first_booking: Booking = null; @@ -1830,10 +1661,11 @@ export class BookingFormService extends AsyncHandler { for (const visitor of members) { if (!visitor.email) continue; this._patch( - this._visitorMemberPatch(visitor, form, { + visitorMemberPatch(visitor, form, { id: '', parent_id, group_name, + fallback_zones: this._booking()?.zones, }), ); const bkn = await this.postForm(true, false).catch((error) => { @@ -1844,7 +1676,7 @@ export class BookingFormService extends AsyncHandler { } } catch (error) { if (rollback_on_group_error && booking_ids.length) { - await this.rollbackGroupBookings(booking_ids); + await rollbackGroupBookings(booking_ids); } throw this._error_message(error); } @@ -1901,9 +1733,9 @@ export class BookingFormService extends AsyncHandler { const is_visitor = type === 'visitor'; const sibling_list = await this.loadGroupSiblings(booking); if (sibling_list.length) { - return this.mapGroupMembersFromBookings(sibling_list, is_visitor); + return mapGroupMembersFromBookings(sibling_list, is_visitor); } - return this.mapGroupMembersFromExtension( + return mapGroupMembersFromExtension( booking.extension_data?.group_members || [], is_visitor, ); @@ -1917,60 +1749,18 @@ export class BookingFormService extends AsyncHandler { const form = this.model() as any; const base_form = { ...form, id: '' }; let parent_id = form.parent_id || form.id; - const group_name = this._groupName(form.group); + const group_name = groupName(form.group); const is_visitor = type === 'visitor'; const needs_group_container_parent = is_visitor && !form.parent_id; const has_group_container_parent = !!form.parent_id && !existing_siblings.some((s) => s.id === form.parent_id); - const sibling_map: Record = {}; - for (const s of existing_siblings) { - const key = is_visitor ? s.asset_id : s.user_email; - if (key) sibling_map[key] = s; - } - const member_keys = new Set(members.map((m) => m.email)); - const to_delete = existing_siblings.filter((s) => { - const key = is_visitor ? s.asset_id : s.user_email; - return key && !member_keys.has(key); - }); - // Event attendee updates write the whole event, so remove guests in order. - for (const booking of to_delete) { - const event = booking.linked_event; - const event_id = event?.event_id || event?.id; - if (is_visitor && event_id) { - await removeEventGuest( - event_id, - new GuestUser({ email: booking.asset_id }), - { - system_id: event.system_id, - calendar: event.resource_calendar, - }, - ); - } - if (is_visitor) { - // Distinguish removed invitees from ordinary cancellations. - await updateBooking(booking.id, { - extension_data: { - ...booking.extension_data, - removed_from_group: true, - }, - }); - } - try { - await removeBooking(booking.id); - } catch (error) { - if (is_visitor) { - await updateBooking(booking.id, { - extension_data: { - removed_from_group: - booking.extension_data?.removed_from_group ?? - false, - }, - }); - } - throw error; - } - } + const { sibling_map, to_delete } = matchGroupSiblings( + existing_siblings, + members, + is_visitor, + ); + await this._removeGroupSiblings(to_delete, is_visitor); const desk_resources = !is_visitor && type === 'desk' ? await this._resolveDeskGroupResources(members, form, [ @@ -2005,12 +1795,13 @@ export class BookingFormService extends AsyncHandler { const booking_id = existing?.id || ''; if (is_visitor) { this._patch( - this._visitorMemberPatch(member, base_form, { + visitorMemberPatch(member, base_form, { id: booking_id, parent_id: booking_id === parent_id ? '' : parent_id, group_name, existing_zones: existing?.zones, + fallback_zones: this._booking()?.zones, }), ); } else { @@ -2036,51 +1827,52 @@ export class BookingFormService extends AsyncHandler { return first_result; } - /** Give each new group its own asset ID and preserve it during edits. */ - private _groupName(existing?: string) { - return existing || `grp-${randomString(24)}`; - } - - /** Form patch for a single visitor in a group flow. */ - private _visitorMemberPatch( - member: User, - base_form: any, - opts: { - id: string; - parent_id: string; - group_name: string; - existing_zones?: string[]; - }, + /** + * Remove bookings of members dropped from a group. Removed visitors are + * also removed from the linked event and marked `removed_from_group`. + */ + private async _removeGroupSiblings( + to_delete: Booking[], + is_visitor: boolean, ) { - const member_name = member.name || member.email; - return { - ...base_form, - id: opts.id, - parent_id: opts.parent_id, - group: opts.group_name, - asset_id: member.email, - asset_name: member_name, - international: - (member as any).international || - !!member.extension_data?.international, - company: (member as any).company || member.organisation, - phone: member.phone, - zones: base_form.zones?.length - ? [...base_form.zones] - : opts.existing_zones?.length - ? [...opts.existing_zones] - : [...(this._booking()?.zones || [])], - assets: [], - attendees: [ - new User({ - name: member_name, - email: member.email, - organisation: - (member as any).company || member.organisation, - phone: member.phone, - }), - ], - }; + // Event attendee updates write the whole event, so remove guests in order. + for (const booking of to_delete) { + const event = booking.linked_event; + const event_id = event?.event_id || event?.id; + if (is_visitor && event_id) { + await removeEventGuest( + event_id, + new GuestUser({ email: booking.asset_id }), + { + system_id: event.system_id, + calendar: event.resource_calendar, + }, + ); + } + if (is_visitor) { + // Distinguish removed invitees from ordinary cancellations. + await updateBooking(booking.id, { + extension_data: { + ...booking.extension_data, + removed_from_group: true, + }, + }); + } + try { + await removeBooking(booking.id); + } catch (error) { + if (is_visitor) { + await updateBooking(booking.id, { + extension_data: { + removed_from_group: + booking.extension_data?.removed_from_group ?? + false, + }, + }); + } + throw error; + } + } } /** Shared success tail for every group flow: reset, retag, show success. */ @@ -2099,16 +1891,22 @@ export class BookingFormService extends AsyncHandler { name: asset?.display_name || asset?.name || asset?.id, description: asset?.name || asset?.id, map_id: asset?.map_id || asset?.id, - zones: (asset?.zone + zones: this._assetZones(asset?.zone), + }; + } + + /** Org, region, building and level zones of a resource on `zone`. */ + private _assetZones(zone?: PlaceZone) { + return ( + zone ? unique([ this._org.organisation.id, this._org.region?.id, - asset.zone?.parent_id, - asset.zone?.id, + zone.parent_id, + zone.id, ]) : [this._org.organisation.id, this._org.region?.id] - ).filter((_) => _), - }; + ).filter((_) => _); } private async createGroupContainerBooking( @@ -2132,7 +1930,7 @@ export class BookingFormService extends AsyncHandler { resource_type: BookingType, id = '', ) { - const group_members = this.mapGroupMembers(resource_type, members); + const group_members = mapGroupMembers(resource_type, members); // The form model carries a stale top-level `group_members` (spread from // the source booking's extension_data when the form was created). The // Booking constructor copies unknown top-level keys into @@ -2244,111 +2042,6 @@ export class BookingFormService extends AsyncHandler { } } - private mapGroupMembers(type: BookingType, members: User[] = []) { - // Dedupe visitors too — a duplicated email produces two group members - // that the visitor list can't tell apart. (PPT-2634) - const user_list = unique( - type === 'visitor' - ? members || [] - : [currentUser(), ...(members || [])], - 'email', - ); - return user_list - .filter((member) => !!member?.email) - .map((member) => ({ - id: member.id || '', - name: member.name || member.email, - email: member.email, - company: (member as any).company || member.organisation || '', - phone: member.phone || '', - international: - !!(member as any).international || - !!member.extension_data?.international, - })); - } - - private mapGroupMembersFromBookings( - bookings: Booking[] = [], - is_visitor = false, - ) { - return unique( - bookings - .map((booking) => { - const group_member = ( - booking.extension_data?.group_members || [] - ).find((member) => member?.email === booking.asset_id); - return is_visitor - ? new User({ - name: - group_member?.name || - booking.extension_data?.visitor_name || - booking.asset_name || - booking.asset_id, - email: booking.asset_id, - organisation: - group_member?.company || - booking.extension_data?.company, - phone: - group_member?.phone || - booking.extension_data?.phone, - extension_data: { - international: !!( - group_member?.international || - booking.extension_data?.international - ), - }, - }) - : new User({ - id: booking.user_id, - name: booking.user_name || booking.user_email, - email: booking.user_email, - organisation: booking.extension_data?.company, - phone: booking.extension_data?.phone, - }); - }) - .filter((member) => !!member?.email), - 'email', - ); - } - - private mapGroupMembersFromExtension( - members: any[] = [], - is_visitor = false, - ) { - return unique( - (members || []) - .filter((member) => !!member?.email) - .map( - (member) => - new User({ - id: member.id || '', - name: member.name || member.email, - email: member.email, - organisation: - member.company || member.organisation || '', - phone: member.phone || '', - extension_data: { - ...(member.extension_data || {}), - international: !!member.international, - }, - international: is_visitor - ? !!member.international - : false, - } as any), - ), - 'email', - ); - } - - private async rollbackGroupBookings(booking_ids: string[]) { - const rollback_errors = ( - await Promise.allSettled(booking_ids.map((id) => removeBooking(id))) - ).filter((_) => _.status === 'rejected'); - if (rollback_errors.length) { - console.error('Failed to rollback group bookings', rollback_errors); - } - } - private async checkQuestions() { if (this._settings.get('app.desks.ignore_questions') !== false) return; const ref = this._dialog.open(DeskQuestionsModalComponent); @@ -2663,112 +2356,44 @@ export class BookingFormService extends AsyncHandler { >; } - public async loadParkingResources(): Promise { - const use_region = this._settings.get('app.use_region'); - const levels = ( - use_region - ? this._org.levelsForRegion() - : this._org.levelsForBuilding() - ).filter((_) => _.tags.includes('parking')); - const spaces = await queryParkingSpacesForZones( - levels.map((l) => l.id), + /** Load parking spaces for the active building or region. */ + public loadParkingResources(): Promise { + return loadParkingResources( + this._org, + this._settings.get('app.use_region'), ); - return spaces.map((s) => ({ - ...s, - id: s.id || s.map_id, - groups: s.place_groups, - zone: this._org.levelWithID([s.zone_id]) as any, - })) as BookingAsset[]; } /** Load desk resources from the assets API for the active scope. */ - public async loadDeskResources(): Promise { - const use_region = this._settings.get('app.use_region'); - const levels = use_region - ? this._org.levelsForRegion() - : this._org.levelsForBuilding(); - const assets = await queryDeskAssetsForZones( - levels.map((level) => level.id), + public loadDeskResources(): Promise { + return loadDeskResources( + this._org, + this._settings.get('app.use_region'), ); - return assets.map((asset) => - deskFromAsset(asset, this._org.levelWithID([asset.zone_id])), - ) as BookingAsset[]; } + /** Load the resources of the given metadata `type`, e.g. `desks`. */ public async loadResourceList(type: string): Promise { if (type === 'desks' && this._settings.get('app.desks.use_assets')) { return this.loadDeskResources(); } - const use_region = this._settings.get('app.use_region'); - const map_metadata = (_) => - (_?.metadata[type]?.details instanceof Array - ? _.metadata[type].details - : [] - ).map((d) => ({ - ...d, - id: d.id || d.map_id, - zone: _.zone, - })); - if (use_region) { - const id = this._org.building.parent_id; - const buildings = this._org.buildings.filter( - (_) => _.parent_id === id, - ); - const lists = await Promise.all( - buildings.map((_) => - listChildMetadata(_.id, { name: type }).then((data) => - flatten(data.map(map_metadata)), - ), - ), - ); - return flatten(lists); - } - const data = await listChildMetadata(this._org.building.id, { - name: type, - }); - return flatten(data.map(map_metadata)); + return loadMetadataResources( + this._org, + type, + this._settings.get('app.use_region'), + ); } - private async _getNearbyResources( + /** Find resources near the map element `id`. Kept on the service so tests + * can stub map lookups. */ + private _getNearbyResources( map_url: string, id: string, resources: BookingAsset[], count: number, reserved_ids = new Set(), ): Promise { - const nearby_resources = []; - let asset_list = resources.filter( - (_) => - !this._resourceReserved(_, reserved_ids) && - !this._resourceMatches(_, id), - ); - for (let i = 0; i < count; i++) { - const item = await findNearbyFeature( - map_url, - id, - asset_list.map((_) => _.map_id || _.id), - ); - if (item) { - const resource = resources.find((_) => - this._resourceMatches(_, item), - ); - if ( - !resource || - this._resourceReserved(resource, reserved_ids) - ) { - asset_list = asset_list.filter( - (_) => !this._resourceMatches(_, item), - ); - continue; - } - nearby_resources.push(resource); - this._reserveResource(resource, reserved_ids); - asset_list = asset_list.filter( - (_) => !this._resourceMatches(_, item), - ); - } - } - return nearby_resources; + return nearbyResources(map_url, id, resources, count, reserved_ids); } private async _resolveDeskGroupResources( @@ -2783,21 +2408,21 @@ export class BookingFormService extends AsyncHandler { for (const booking of existing_siblings) { if (booking.user_email) existing_map[booking.user_email] = booking; } - const selected_resource = this._findResourceById( + const selected_resource = findResourceById( available_resources, preferred_id, ); const preferred_resource = selected_resource || (existing_siblings.length - ? this._findResourceById(all_resources, preferred_id) + ? findResourceById(all_resources, preferred_id) : null); if (!selected_resource && !existing_siblings.length) { throw i18n('BOOKINGS.DESK_AVAILABLE_ERROR'); } const anchor_resource = preferred_resource || - this._findResourceById( + findResourceById( all_resources, existing_siblings[0]?.asset_id || '', ); @@ -2813,12 +2438,12 @@ export class BookingFormService extends AsyncHandler { ? preferred_id : booking?.asset_id || ''; const resource = - this._findResourceById(all_resources, resource_id) || - this._findResourceById(available_resources, resource_id); - if (!resource || this._resourceReserved(resource, reserved_ids)) { + findResourceById(all_resources, resource_id) || + findResourceById(available_resources, resource_id); + if (!resource || resourceReserved(resource, reserved_ids)) { return null; } - this._reserveResource(resource, reserved_ids); + reserveResource(resource, reserved_ids); return resource; }); const missing_count = resolved.filter((_) => !_).length; @@ -2853,33 +2478,4 @@ export class BookingFormService extends AsyncHandler { } return final_resources; } - - private _findResourceById(resources: BookingAsset[], id: string) { - return (resources || []).find((_) => this._resourceMatches(_, id)); - } - - private _resourceMatches(resource: Partial, id: string) { - if (!resource || !id) return false; - return resource.id === id || resource.map_id === id; - } - - private _resourceReserved( - resource: Partial, - reserved_ids: Set, - ) { - return !!( - resource && - ((resource.id && reserved_ids.has(resource.id)) || - (resource.map_id && reserved_ids.has(resource.map_id))) - ); - } - - private _reserveResource( - resource: Partial, - reserved_ids: Set, - ) { - if (!resource) return; - if (resource.id) reserved_ids.add(resource.id); - if (resource.map_id) reserved_ids.add(resource.map_id); - } } diff --git a/libs/bookings/src/lib/booking-form.types.ts b/libs/bookings/src/lib/booking-form.types.ts new file mode 100644 index 0000000000..3fe36bbc6f --- /dev/null +++ b/libs/bookings/src/lib/booking-form.types.ts @@ -0,0 +1,50 @@ +import { BookingType, User } from '@placeos/common'; +import { PlaceZone } from '@placeos/ts-client'; + +export type BookingFlowView = 'form' | 'map' | 'confirm' | 'success'; + +export interface BookingFlowOptions { + /** Type of booking being made */ + type: BookingType; + /** Zone to check available */ + zone_id?: string; + /** List of features that the asset should associate */ + features?: string[]; + /** Whether booking is for a group */ + group?: boolean; + /** Recurrence Pattern */ + pattern?: 'none' | 'daily' | 'weekly' | 'monthly'; + /** Recurrence ending */ + recurr_end?: number; + /** List of group members to book for */ + members?: User[]; + /** Whether to only show favourite rooms */ + show_fav?: boolean; + /** Whether to group bookings */ + disable_date?: boolean; + /** Whether resource has accessibility options */ + show_accessible?: boolean; +} + +export interface BookingAsset { + id: string; + map_id?: string; + display_name?: string; + name: string; + bookable: boolean; + zone?: PlaceZone; + level?: PlaceZone; + location?: string; + images?: string[]; + groups?: string[]; + assigned_to?: string; + features: string[]; +} + +export interface GroupBookingFailure { + email: string; + name: string; + asset_id?: string; + asset_name?: string; + error: string; +} diff --git a/libs/bookings/src/lib/booking-group.utilities.ts b/libs/bookings/src/lib/booking-group.utilities.ts new file mode 100644 index 0000000000..8e5210102b --- /dev/null +++ b/libs/bookings/src/lib/booking-group.utilities.ts @@ -0,0 +1,193 @@ +import { + Booking, + BookingType, + currentUser, + randomString, + unique, + User, +} from '@placeos/common'; +import { removeBooking } from './bookings.fn'; + +/** Give each new group its own asset ID and preserve it during edits. */ +export function groupName(existing?: string) { + return existing || `grp-${randomString(24)}`; +} + +/** Form patch for a single visitor in a group flow. Zones fall back from the + * form, to the visitor's existing booking, to `fallback_zones`. */ +export function visitorMemberPatch( + member: User, + base_form: any, + opts: { + id: string; + parent_id: string; + group_name: string; + existing_zones?: string[]; + fallback_zones?: string[]; + }, +) { + const member_name = member.name || member.email; + return { + ...base_form, + id: opts.id, + parent_id: opts.parent_id, + group: opts.group_name, + asset_id: member.email, + asset_name: member_name, + international: + (member as any).international || + !!member.extension_data?.international, + company: (member as any).company || member.organisation, + phone: member.phone, + zones: base_form.zones?.length + ? [...base_form.zones] + : opts.existing_zones?.length + ? [...opts.existing_zones] + : [...(opts.fallback_zones || [])], + assets: [], + attendees: [ + new User({ + name: member_name, + email: member.email, + organisation: (member as any).company || member.organisation, + phone: member.phone, + }), + ], + }; +} + +/** Group member details stored in `extension_data.group_members`. Non-visitor + * groups always include the current user. */ +export function mapGroupMembers(type: BookingType, members: User[] = []) { + // Dedupe visitors too — a duplicated email produces two group members + // that the visitor list can't tell apart. (PPT-2634) + const user_list = unique( + type === 'visitor' + ? members || [] + : [currentUser(), ...(members || [])], + 'email', + ); + return user_list + .filter((member) => !!member?.email) + .map((member) => ({ + id: member.id || '', + name: member.name || member.email, + email: member.email, + company: (member as any).company || member.organisation || '', + phone: member.phone || '', + international: + !!(member as any).international || + !!member.extension_data?.international, + })); +} + +/** Group members rebuilt from the sibling bookings of a group. Visitor + * bookings hold the visitor in `asset_id`, other bookings in `user_*`. */ +export function mapGroupMembersFromBookings( + bookings: Booking[] = [], + is_visitor = false, +) { + return unique( + bookings + .map((booking) => { + const group_member = ( + booking.extension_data?.group_members || [] + ).find((member) => member?.email === booking.asset_id); + return is_visitor + ? new User({ + name: + group_member?.name || + booking.extension_data?.visitor_name || + booking.asset_name || + booking.asset_id, + email: booking.asset_id, + organisation: + group_member?.company || + booking.extension_data?.company, + phone: + group_member?.phone || + booking.extension_data?.phone, + extension_data: { + international: !!( + group_member?.international || + booking.extension_data?.international + ), + }, + }) + : new User({ + id: booking.user_id, + name: booking.user_name || booking.user_email, + email: booking.user_email, + organisation: booking.extension_data?.company, + phone: booking.extension_data?.phone, + }); + }) + .filter((member) => !!member?.email), + 'email', + ); +} + +/** Group members rebuilt from a booking's `extension_data.group_members`. */ +export function mapGroupMembersFromExtension( + members: any[] = [], + is_visitor = false, +) { + return unique( + (members || []) + .filter((member) => !!member?.email) + .map( + (member) => + new User({ + id: member.id || '', + name: member.name || member.email, + email: member.email, + organisation: + member.company || member.organisation || '', + phone: member.phone || '', + extension_data: { + ...(member.extension_data || {}), + international: !!member.international, + }, + international: is_visitor + ? !!member.international + : false, + } as any), + ), + 'email', + ); +} + +/** + * Match the existing bookings of a group to its members by email. Visitor + * bookings hold the email in `asset_id`, other bookings in `user_email`. + * Bookings with no matching member are returned in `to_delete`. + */ +export function matchGroupSiblings( + existing_siblings: Booking[], + members: User[], + is_visitor: boolean, +) { + const keyOf = (booking: Booking) => + is_visitor ? booking.asset_id : booking.user_email; + const sibling_map: Record = {}; + for (const booking of existing_siblings) { + const key = keyOf(booking); + if (key) sibling_map[key] = booking; + } + const member_keys = new Set(members.map((m) => m.email)); + const to_delete = existing_siblings.filter((booking) => { + const key = keyOf(booking); + return key && !member_keys.has(key); + }); + return { sibling_map, to_delete }; +} + +/** Remove the bookings of a failed group flow. Failures are only logged. */ +export async function rollbackGroupBookings(booking_ids: string[]) { + const rollback_errors = ( + await Promise.allSettled(booking_ids.map((id) => removeBooking(id))) + ).filter((_) => _.status === 'rejected'); + if (rollback_errors.length) { + console.error('Failed to rollback group bookings', rollback_errors); + } +} diff --git a/libs/bookings/src/lib/booking-resource.loaders.ts b/libs/bookings/src/lib/booking-resource.loaders.ts new file mode 100644 index 0000000000..52b4cf36d2 --- /dev/null +++ b/libs/bookings/src/lib/booking-resource.loaders.ts @@ -0,0 +1,113 @@ +import { + deskFromAsset, + queryDeskAssetsForZones, + queryParkingSpacesForZones, +} from '@placeos/assets'; +import { Desk, flatten, OrganisationService } from '@placeos/common'; +import { listChildMetadata } from '@placeos/ts-client'; +import type { BookingAsset } from './booking-form.types'; + +/** Load parking spaces on the parking levels of the active building or region. */ +export async function loadParkingResources( + org: OrganisationService, + use_region: boolean, +): Promise { + const levels = ( + use_region ? org.levelsForRegion() : org.levelsForBuilding() + ).filter((_) => _.tags.includes('parking')); + const spaces = await queryParkingSpacesForZones(levels.map((l) => l.id)); + return spaces.map((s) => ({ + ...s, + id: s.id || s.map_id, + groups: s.place_groups, + zone: org.levelWithID([s.zone_id]) as any, + })) as BookingAsset[]; +} + +/** Load desk resources from the assets API for the active building or region. */ +export async function loadDeskResources( + org: OrganisationService, + use_region: boolean, +): Promise { + const levels = use_region ? org.levelsForRegion() : org.levelsForBuilding(); + const assets = await queryDeskAssetsForZones( + levels.map((level) => level.id), + ); + return assets.map((asset) => + deskFromAsset(asset, org.levelWithID([asset.zone_id])), + ) as BookingAsset[]; +} + +/** + * Load the resources stored in the `type` metadata of every level in the + * active building, or in every building of its region. + */ +export async function loadMetadataResources( + org: OrganisationService, + type: string, + use_region: boolean, +): Promise { + const map_metadata = (_) => + (_?.metadata[type]?.details instanceof Array + ? _.metadata[type].details + : [] + ).map((d) => ({ + ...d, + id: d.id || d.map_id, + zone: _.zone, + })); + if (use_region) { + const id = org.building.parent_id; + const buildings = org.buildings.filter((_) => _.parent_id === id); + const lists = await Promise.all( + buildings.map((_) => + listChildMetadata(_.id, { name: type }).then((data) => + flatten(data.map(map_metadata)), + ), + ), + ); + return flatten(lists); + } + const data = await listChildMetadata(org.building.id, { + name: type, + }); + return flatten(data.map(map_metadata)); +} + +/** + * Whether a desk in any active building is assigned to `user_email`. Reads + * desk assets when `use_assets` is set, otherwise the desks metadata. + */ +export async function hasAssignedDesk( + org: OrganisationService, + user_email: string, + use_assets: boolean, +): Promise { + const buildings = org.building_list(); + if (!(buildings?.length > 0)) return false; + const email = user_email?.toLowerCase(); + if (!email) return false; + if (use_assets) { + const building_ids = new Set(buildings.map((building) => building.id)); + const level_ids = org.levels + .filter((level) => building_ids.has(level.parent_id)) + .map((level) => level.id); + const desks = await queryDeskAssetsForZones(level_ids).catch(() => []); + return desks.some((desk) => desk.assigned_to?.toLowerCase() === email); + } + const map_metadata = (meta) => + (meta?.metadata?.desks?.details instanceof Array + ? meta.metadata.desks.details + : [] + ).map((desk) => new Desk({ ...desk, zone: meta.zone })); + const desk_lists = await Promise.all( + buildings.map((building) => + listChildMetadata(building.id, { name: 'desks' }) + .then((data) => flatten(data.map(map_metadata))) + .catch(() => [] as Desk[]), + ), + ); + return flatten(desk_lists).some( + (desk) => desk.assigned_to?.toLowerCase() === email, + ); +} diff --git a/libs/bookings/src/lib/booking-resource.utilities.ts b/libs/bookings/src/lib/booking-resource.utilities.ts new file mode 100644 index 0000000000..421745ec52 --- /dev/null +++ b/libs/bookings/src/lib/booking-resource.utilities.ts @@ -0,0 +1,126 @@ +import type { BookingAsset } from './booking-form.types'; +import { findNearbyFeature } from './booking.utilities'; + +/** Whether the resource has the given ID or map ID. */ +export function resourceMatches(resource: Partial, id: string) { + if (!resource || !id) return false; + return resource.id === id || resource.map_id === id; +} + +/** Find the resource with the given ID or map ID. */ +export function findResourceById(resources: BookingAsset[], id: string) { + return (resources || []).find((_) => resourceMatches(_, id)); +} + +/** Whether the resource is already reserved for another group member. */ +export function resourceReserved( + resource: Partial, + reserved_ids: Set, +) { + return !!( + resource && + ((resource.id && reserved_ids.has(resource.id)) || + (resource.map_id && reserved_ids.has(resource.map_id))) + ); +} + +/** Reserve the resource so no other group member gets it. */ +export function reserveResource( + resource: Partial, + reserved_ids: Set, +) { + if (!resource) return; + if (resource.id) reserved_ids.add(resource.id); + if (resource.map_id) reserved_ids.add(resource.map_id); +} + +/** + * Find up to `count` unreserved resources closest to the map element `id`. + * Found resources are added to `reserved_ids`. + */ +export async function nearbyResources( + map_url: string, + id: string, + resources: BookingAsset[], + count: number, + reserved_ids = new Set(), +): Promise { + const nearby_resources: BookingAsset[] = []; + let asset_list = resources.filter( + (_) => !resourceReserved(_, reserved_ids) && !resourceMatches(_, id), + ); + for (let i = 0; i < count; i++) { + const item = await findNearbyFeature( + map_url, + id, + asset_list.map((_) => _.map_id || _.id), + ); + if (item) { + const resource = resources.find((_) => resourceMatches(_, item)); + if (!resource || resourceReserved(resource, reserved_ids)) { + asset_list = asset_list.filter( + (_) => !resourceMatches(_, item), + ); + continue; + } + nearby_resources.push(resource); + reserveResource(resource, reserved_ids); + asset_list = asset_list.filter((_) => !resourceMatches(_, item)); + } + } + return nearby_resources; +} + +/** Split the resources into groups of `member_count` that share a level. */ +export function groupAvailability( + resources: BookingAsset[], + member_count: number, +): BookingAsset[][] { + const groups: BookingAsset[][] = []; + const asset_list = [...resources].sort((a, b) => + a.zone?.id?.localeCompare(b.zone?.id), + ); + // Each pass pops at least one asset, so this ends when the list is empty. + while (asset_list.length) { + const group: BookingAsset[] = []; + let asset = asset_list.pop(); + while (group.length < member_count) { + if ( + group.length && + !group.find((_) => _.zone?.id === asset.zone?.id) + ) { + break; + } + group.push(asset); + asset = asset_list.pop(); + } + if (group.length < member_count) continue; + groups.push(group); + } + return groups; +} + +/** Pick a random resource on the level with the most available resources. */ +export function pickAutoAllocatedResource( + available: BookingAsset[], +): BookingAsset { + // Group available resources by zone (level) id + const zone_map: Record = {}; + for (const asset of available) { + const zone_id = asset.zone?.id || 'unknown'; + if (!zone_map[zone_id]) zone_map[zone_id] = []; + zone_map[zone_id].push(asset); + } + // Find the level with the most free resources + let best_zone_id = ''; + let best_count = 0; + for (const zone_id in zone_map) { + if (zone_map[zone_id].length > best_count) { + best_count = zone_map[zone_id].length; + best_zone_id = zone_id; + } + } + const candidates = zone_map[best_zone_id]; + // Select a random resource from that level + return candidates[Math.floor(Math.random() * candidates.length)]; +} diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index 56d5e2cf41..96d3fedc37 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -1,43 +1,18 @@ -import { - Injector, - signal, - untracked, - type WritableSignal, -} from '@angular/core'; -import { - disabled, - email, - form, - required, - validate, - type FieldTree, -} from '@angular/forms/signals'; import { queryLockerAssetsForZones, queryLockerBankAssetsForZones, } from '@placeos/assets'; import { Booking, - BookingType, CalendarEvent, - current_user, - currentUser, fromEventRecurrence, - guardModelUndefinedWrites, - onFieldChange, OrganisationService, Point, - settingSignal, - setupFormTimeSync, toBookingRecurrence, unique, - User, - type FormTimeSyncHandle, - type SignalFormRef, } from '@placeos/common'; import { getMapDetails } from '@placeos/components'; import { PlaceAsset } from '@placeos/ts-client'; -import { addMinutes, isAfter } from 'date-fns'; import { Locker, LockerBank } from './locker.class'; function parseJson(value: string, fallback: T): T { @@ -106,31 +81,6 @@ export function lockerFromAsset( } as Locker; } -function setBookingAsset( - model: WritableSignal, - resource: any, -) { - if (!resource) { - // Use '' rather than `undefined`: an undefined value removes the - // `asset_id` sub-field from the signal-forms FieldTree, breaking any - // `[formField]="form.asset_id"` binding (`this.field() is not a function`). - model.update((m) => ({ ...m, asset_id: '' })); - return; - } - model.update((m) => ({ - ...m, - asset_id: resource.id, - asset_name: resource.name, - name: resource.display_name || resource.name || resource.id, - map_id: resource.map_id || resource.id, - description: resource.name, - zones: resource.zone - ? [resource.zone?.parent_id, resource.zone?.id] - : [], - booking_asset: resource, - })); -} - const visitorGroupMemberName = (booking: Booking) => { const member = (booking.extension_data?.group_members || []).find( (item) => item?.email === booking.asset_id, @@ -148,7 +98,9 @@ const visitorAttendeeName = (booking: Booking) => { return name || ''; }; -const formatEmailName = (value: string) => { +/** Readable name from an email address, e.g. `jane.doe@x.com` → `Jane Doe`. + * Values without an `@` are returned unchanged. */ +export const formatEmailName = (value: string) => { if (!value.includes('@')) return value; const [local_part] = value.split('@'); const formatted_local = local_part @@ -182,303 +134,6 @@ export const visitorDisplayNameFor = (booking: Booking) => { return formatEmailName(asset_id || asset_name || 'Visitor'); }; -/** Raw value held by the booking form model. */ -export interface BookingFormValue { - id: string; - parent_id: string; - event_id: string; - ical_uid: string; - date: number; - date_end: number; - all_day: boolean; - name: string; - duration: number; - booking_type: BookingType; - zones: string[]; - title: string; - description: string; - booking_asset: any; - resources: any[]; - company: string; - asset_id: string; - asset_name: string; - assets: any[]; - attendees: any[]; - map_id: string; - featured: boolean; - user: User; - user_id: string; - group: any; - user_email: string; - user_name: string; - timezone: string; - booked_by: User; - booked_by_id: string; - booked_by_email: string; - secondary_resource: any; - location: string; - attendance_type: string; - phone?: string; - permission: string; - images: any[]; - tags: string[]; - plate_number: string; - vehicle_type: string; - request_type: string; - requires_manual_approval: boolean; - space_restrictions: string | boolean; - extra_space_restrictions: any[]; - approver_group: string; - prefer_booked_location_first: boolean; - pass_number: string; - international: boolean; - recurrence_custom: boolean; - recurrence_type: string; - recurrence_days: any; - recurrence_nth_of_month: any; - recurrence_interval: any; - recurrence_end: any; - recurrence_instances: any; - notes: string; - attachments: string[]; - update_master: boolean; - self_registered: boolean; - is_assgined: boolean; -} - -export function bookingAttachments(booking: Booking = new Booking()): string[] { - booking = booking || new Booking(); - const extension_data = booking.extension_data || {}; - return [ - ...(extension_data.attachments || []), - ...(extension_data.p2_document_names || []), - ].filter((item) => !!item); -} - -/** Host (`user`) of a booking, falling back to the signed-in user for new - * bookings. Delegate bookings are made on behalf of someone else, so seeding - * this from the current user would silently reassign the host on save. */ -export function bookingHostUser(booking: Booking = new Booking()): User { - if (!booking?.user_email) return currentUser(); - return new User({ - id: booking.user_id || '', - email: booking.user_email, - name: booking.user_name || booking.user_email, - }); -} - -/** Build the raw booking form value from a booking. */ -export function bookingFormValue( - booking: Booking = new Booking(), -): BookingFormValue { - const extension_data = booking.extension_data || {}; - const visitor_name = - booking.booking_type === 'visitor' - ? extension_data.visitor_name || booking.asset_name || '' - : booking.asset_name || booking.description; - // Every field must be non-`undefined`: the signal-forms FieldTree only - // exposes a sub-field for keys whose value is defined, so an `undefined` - // seed would make `[formField]="form.x"` bind to nothing at runtime. - return { - id: booking.id || '', - parent_id: booking.parent_id || '', - event_id: booking.event_id || '', - ical_uid: extension_data.ical_uid || '', - date: booking.date ?? 0, - date_end: booking.date_end ?? 0, - all_day: booking.all_day ?? false, - name: extension_data.name || booking.asset_name || '', - duration: booking.duration ?? 0, - booking_type: booking.booking_type, - zones: booking.zones || [], - title: booking.title || '', - description: booking.description || '', - booking_asset: {}, - resources: [], - company: extension_data.company || '', - asset_id: booking.asset_id || '', - asset_name: visitor_name || '', - assets: extension_data.assets || [], - attendees: booking.attendees || [], - map_id: extension_data.map_id || '', - featured: extension_data.featured || false, - user: bookingHostUser(booking), - user_id: booking.user_id || '', - group: booking.group ?? {}, - user_email: booking.user_email || '', - user_name: booking.user_name || '', - timezone: booking.timezone || '', - booked_by: currentUser(), - booked_by_id: booking.booked_by_id || '', - booked_by_email: booking.booked_by_email || '', - secondary_resource: - extension_data.other_asset_type || - extension_data.secondary_resource || - {}, - location: extension_data.location || '', - attendance_type: extension_data.attendance_type || 'ANY', - phone: extension_data.phone || '', - permission: booking.permission || 'PRIVATE', - images: booking.images || [], - tags: booking?.tags || [], - plate_number: extension_data.plate_number || '', - vehicle_type: extension_data.vehicle_type || 'car', - request_type: extension_data.request_type || 'standard', - requires_manual_approval: - extension_data.requires_manual_approval ?? false, - space_restrictions: extension_data.space_restrictions ?? false, - extra_space_restrictions: extension_data.extra_space_restrictions ?? [], - approver_group: extension_data.approver_group || '', - prefer_booked_location_first: - extension_data.prefer_booked_location_first ?? false, - pass_number: extension_data.pass_number || '', - international: extension_data.international ?? false, - recurrence_custom: extension_data.recurrence_custom ?? false, - recurrence_type: booking.recurrence_type || 'none', - recurrence_days: booking.recurrence_days ?? 0, - recurrence_nth_of_month: booking.recurrence_nth_of_month ?? 0, - recurrence_interval: booking.recurrence_interval ?? 0, - recurrence_end: booking.recurrence_end ?? 0, - recurrence_instances: extension_data.recurrence_instances ?? 0, - notes: extension_data.notes || '', - attachments: bookingAttachments(booking), - update_master: false, - self_registered: false, - is_assgined: false, - } as BookingFormValue; -} - -/** FieldTree for the booking form, with attached time-sync handle. */ -export type BookingForm = FieldTree & { - _time_sync?: FormTimeSyncHandle; -}; - -export type BookingFormRef = SignalFormRef & { - /** Time-sync handle for runtime duration/bookable-hours reconfiguration. */ - time_sync: FormTimeSyncHandle; -}; - -export function generateBookingForm( - booking: Booking = new Booking(), - injector?: Injector, -): BookingFormRef { - const started = booking.state === 'started'; - const model: WritableSignal = signal( - bookingFormValue(booking), - ); - - // Keep every key defined so signal-forms never drops a sub-field bound via - // `[formField]` (an undefined value triggers `this.field() is not a - // function`). Guards writes synchronously — no reactive surface. - guardModelUndefinedWrites(model, bookingFormValue(new Booking())); - - const require_plate_number = settingSignal( - 'parking.require_plate_number', - false, - ); - const require_space_restriction = settingSignal( - 'parking.require_space_restriction', - false, - ); - const booking_form = form( - model, - (p) => { - required(p.date); - required(p.asset_id); - // Visitor bookings use asset_id to hold the visitor's email address. - email(p.asset_id, { - when: ({ valueOf }) => valueOf(p.booking_type) === 'visitor', - }); - // Parking requests can require a vehicle plate number via settings. - // Scope to parking bookings so the required state never bleeds into - // desk/visitor forms sharing this FieldTree. - required(p.plate_number, { - when: ({ valueOf }) => - valueOf(p.booking_type) === 'parking' && - require_plate_number(), - }); - validate(p.plate_number, ({ value, valueOf }) => - valueOf(p.booking_type) === 'parking' && - require_plate_number() && - !`${value() || ''}`.trim() - ? { kind: 'required' } - : undefined, - ); - validate(p.space_restrictions, ({ value, valueOf }) => - valueOf(p.booking_type) === 'parking' && - require_space_restriction() && - !value() - ? { kind: 'required' } - : undefined, - ); - validate(p.duration, ({ value, valueOf }) => { - const date = valueOf(p.date); - if (value() <= 0) return { kind: 'duration' }; - return date && isAfter(Date.now(), addMinutes(date, value())) - ? { kind: 'duration' } - : undefined; - }); - // Depend only on the date field's own value (`ctx.value()`) and read - // `id` untracked. `valueOf(p.id)` here threw NG01901 (the resolver - // can't navigate `.id` from the `date` field), and reading the whole - // `model()` made this recompute on every model change — extra reactive - // surface that could feed loops on the explore page. - disabled(p.date, ({ value }) => { - if (started) return true; - return value() < Date.now() && !!untracked(model).id; - }); - }, - { injector }, - ) as BookingForm; - - // user → user_id/email/name - onFieldChange( - model, - (v) => v.user, - (user) => { - if (!user) return; - // Coalesce to '' so the sub-fields are never removed from the - // FieldTree (an undefined value breaks `[formField]="form.user_*"`). - model.update((m) => ({ - ...m, - user, - user_id: (user as any)?.id ?? '', - user_email: (user as any)?.email ?? '', - user_name: (user as any)?.name ?? '', - })); - }, - injector, - ); - // resources → booking asset fields - onFieldChange( - model, - (v) => v.resources, - (resources) => { - // Visitor bookings store the visitor email in `asset_id`; they do - // not select a booking resource. Form resets replace `resources` - // with a new empty array, which must not clear that email on edit. - if (untracked(model).booking_type === 'visitor') return; - setBookingAsset(model, (resources || [])[0]); - }, - injector, - ); - // Keep booked_by synced to the current user - current_user.subscribe((user) => { - if (!user) return; - model.update((m) => ({ - ...m, - booked_by: user, - booked_by_id: user?.id, - booked_by_email: user?.email, - })); - }); - - const time_sync = setupFormTimeSync(model, {}, injector); - (booking_form as BookingForm)._time_sync = time_sync; - (model as any)._time_sync = time_sync; - return { model, form: booking_form, time_sync }; -} - export async function findNearbyFeature( map_url: string, centered_at: Point | string, diff --git a/libs/bookings/src/lib/desk-list-field.component.ts b/libs/bookings/src/lib/desk-list-field.component.ts index 8f9c2cde41..974fb222a1 100644 --- a/libs/bookings/src/lib/desk-list-field.component.ts +++ b/libs/bookings/src/lib/desk-list-field.component.ts @@ -10,7 +10,7 @@ import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset } from './booking-form.service'; +import { BookingAsset } from './booking-form.types'; import { DeskSelectModalComponent } from './desk-select-modal/desk-select-modal.component'; const EMPTY_FAVS: string[] = []; diff --git a/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts index 79f930a683..77ceced529 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-details.component.ts @@ -7,7 +7,7 @@ import { ImageCarouselComponent } from 'libs/components/src/lib/image-carousel.c import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map.component'; import { MapPinComponent } from 'libs/components/src/lib/map-pin.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; @Component({ selector: 'desk-details', diff --git a/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts index e44c43bd42..fb40bb0904 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-list.component.ts @@ -4,7 +4,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset, BookingFormService } from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; @Component({ selector: 'desk-list', diff --git a/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts index a97597291c..e42d8a7f46 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-map.component.ts @@ -23,7 +23,8 @@ import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { ExploreDeskInfoComponent } from 'libs/explore/src/lib/explore-desk-info.component'; import { DEFAULT_COLOURS } from 'libs/explore/src/lib/explore-spaces.service'; -import { BookingAsset, BookingFormService } from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; @Component({ selector: 'desk-map', diff --git a/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts index f339d4937e..0d77c99db2 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts @@ -11,11 +11,8 @@ import { isMobileSafari, SettingsService } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { - BookingAsset, - BookingFlowOptions, - BookingFormService, -} from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset, BookingFlowOptions } from '../booking-form.types'; import { DeskDetailsComponent } from './desk-details.component'; import { DeskFiltersDisplayComponent } from './desk-filters-display.component'; import { DeskFiltersComponent } from './desk-filters.component'; diff --git a/libs/bookings/src/lib/invite-visitor-form.component.ts b/libs/bookings/src/lib/invite-visitor-form.component.ts index 2c311a3700..677e932de0 100644 --- a/libs/bookings/src/lib/invite-visitor-form.component.ts +++ b/libs/bookings/src/lib/invite-visitor-form.component.ts @@ -52,7 +52,7 @@ import { TimeFieldComponent } from 'libs/form-fields/src/lib/time-field.componen import { UserListFieldComponent } from 'libs/form-fields/src/lib/user-list-field.component'; import { UserSearchFieldComponent } from 'libs/form-fields/src/lib/user-search-field.component'; import { BookingFormService } from './booking-form.service'; -import { bookingHostUser } from './booking.utilities'; +import { bookingHostUser } from './booking-form.model'; @Component({ selector: `invite-visitor-form`, diff --git a/libs/bookings/src/lib/locker-list-field.component.ts b/libs/bookings/src/lib/locker-list-field.component.ts index be6b7a75d3..e4f6128455 100644 --- a/libs/bookings/src/lib/locker-list-field.component.ts +++ b/libs/bookings/src/lib/locker-list-field.component.ts @@ -11,7 +11,7 @@ import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticat import { IconComponent } from 'libs/components/src/lib/icon.component'; import { LevelPipe } from 'libs/components/src/lib/level.pipe'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset } from './booking-form.service'; +import { BookingAsset } from './booking-form.types'; import { LockerSelectModalComponent } from './locker-select-modal/locker-select-modal.component'; const EMPTY_FAVS: string[] = []; diff --git a/libs/bookings/src/lib/locker-select-modal/locker-bank-list.component.ts b/libs/bookings/src/lib/locker-select-modal/locker-bank-list.component.ts index a72e5c1e34..a080f439e6 100644 --- a/libs/bookings/src/lib/locker-select-modal/locker-bank-list.component.ts +++ b/libs/bookings/src/lib/locker-select-modal/locker-bank-list.component.ts @@ -13,7 +13,8 @@ import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticat import { IconComponent } from 'libs/components/src/lib/icon.component'; import { LevelPipe } from 'libs/components/src/lib/level.pipe'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset, BookingFormService } from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; import { loadLockerBanksForScope, loadLockersForScope, diff --git a/libs/bookings/src/lib/locker-select-modal/locker-map.component.ts b/libs/bookings/src/lib/locker-select-modal/locker-map.component.ts index 3982dae204..0f19d036c8 100644 --- a/libs/bookings/src/lib/locker-select-modal/locker-map.component.ts +++ b/libs/bookings/src/lib/locker-select-modal/locker-map.component.ts @@ -23,7 +23,8 @@ import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { ExploreDeskInfoComponent } from 'libs/explore/src/lib/explore-desk-info.component'; import { DEFAULT_COLOURS } from 'libs/explore/src/lib/explore-spaces.service'; -import { BookingAsset, BookingFormService } from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; import { loadLockerBanksForScope, loadLockersForScope, diff --git a/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts b/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts index 987cad8b84..127cf6ec7b 100644 --- a/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts +++ b/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts @@ -11,11 +11,8 @@ import { isMobileSafari, SettingsService } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { - BookingAsset, - BookingFlowOptions, - BookingFormService, -} from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset, BookingFlowOptions } from '../booking-form.types'; import { LockerGridComponent } from '../locker-grid.component'; import { LockerBank } from '../locker.class'; import { LockerBankListComponent } from './locker-bank-list.component'; diff --git a/libs/bookings/src/lib/parking-select-modal/parking-details.component.ts b/libs/bookings/src/lib/parking-select-modal/parking-details.component.ts index ab0faa7fab..3e5f4f40be 100644 --- a/libs/bookings/src/lib/parking-select-modal/parking-details.component.ts +++ b/libs/bookings/src/lib/parking-select-modal/parking-details.component.ts @@ -6,7 +6,7 @@ import { IconComponent } from 'libs/components/src/lib/icon.component'; import { ImageCarouselComponent } from 'libs/components/src/lib/image-carousel.component'; import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; @Component({ selector: `parking-space-details`, diff --git a/libs/bookings/src/lib/parking-select-modal/parking-list.component.ts b/libs/bookings/src/lib/parking-select-modal/parking-list.component.ts index f31c48e2e1..d95cb94a61 100644 --- a/libs/bookings/src/lib/parking-select-modal/parking-list.component.ts +++ b/libs/bookings/src/lib/parking-select-modal/parking-list.component.ts @@ -5,7 +5,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset, BookingFormService } from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; @Component({ selector: `parking-space-list`, diff --git a/libs/bookings/src/lib/parking-select-modal/parking-map.component.ts b/libs/bookings/src/lib/parking-select-modal/parking-map.component.ts index 0113d4356e..d0ded61714 100644 --- a/libs/bookings/src/lib/parking-select-modal/parking-map.component.ts +++ b/libs/bookings/src/lib/parking-select-modal/parking-map.component.ts @@ -22,7 +22,8 @@ import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { ExploreParkingInfoComponent } from 'libs/explore/src/lib/explore-parking-info.component'; import { DEFAULT_COLOURS } from 'libs/explore/src/lib/explore-spaces.service'; -import { BookingAsset, BookingFormService } from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset } from '../booking-form.types'; @Component({ selector: 'parking-space-map', diff --git a/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts b/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts index 32ca11fdbe..294b76f74a 100644 --- a/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts +++ b/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts @@ -11,11 +11,8 @@ import { isMobileSafari, SettingsService } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { - BookingAsset, - BookingFlowOptions, - BookingFormService, -} from '../booking-form.service'; +import { BookingFormService } from '../booking-form.service'; +import { BookingAsset, BookingFlowOptions } from '../booking-form.types'; import { ParkingDetailsComponent } from './parking-details.component'; import { ParkingFiltersDisplayComponent } from './parking-filters-display.component'; diff --git a/libs/bookings/src/lib/parking-space-list-field.component.ts b/libs/bookings/src/lib/parking-space-list-field.component.ts index 95a3264dc4..ee74edfc60 100644 --- a/libs/bookings/src/lib/parking-space-list-field.component.ts +++ b/libs/bookings/src/lib/parking-space-list-field.component.ts @@ -7,7 +7,7 @@ import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { BookingAsset } from './booking-form.service'; +import { BookingAsset } from './booking-form.types'; import { FAV_PARKING_KEY, ParkingSelectModalComponent, diff --git a/libs/bookings/src/test/booking.utilities.spec.ts b/libs/bookings/src/test/booking.utilities.spec.ts index 723edc747f..9ceffcf34c 100644 --- a/libs/bookings/src/test/booking.utilities.spec.ts +++ b/libs/bookings/src/test/booking.utilities.spec.ts @@ -9,8 +9,8 @@ import { User, WeekOfMonth, } from '@placeos/common'; +import { generateBookingForm } from '../lib/booking-form.model'; import { - generateBookingForm, loadLockersForScope, newBookingFromCalendarEvent, parkingRequestStatus, diff --git a/libs/bookings/src/test/desk-filters.component.spec.ts b/libs/bookings/src/test/desk-filters.component.spec.ts index 47c708ec43..faff9cc78d 100644 --- a/libs/bookings/src/test/desk-filters.component.spec.ts +++ b/libs/bookings/src/test/desk-filters.component.spec.ts @@ -16,8 +16,8 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { DateFieldComponent } from 'libs/form-fields/src/lib/date-field.component'; import { DurationFieldComponent } from 'libs/form-fields/src/lib/duration-field.component'; import { TimeFieldComponent } from 'libs/form-fields/src/lib/time-field.component'; +import { generateBookingForm } from '../lib/booking-form.model'; import { BookingFormService } from '../lib/booking-form.service'; -import { generateBookingForm } from '../lib/booking.utilities'; import { DeskFiltersComponent } from '../lib/desk-select-modal/desk-filters.component'; describe('DeskFiltersComponent', () => { diff --git a/libs/bookings/src/test/invite-visitor-form.component.spec.ts b/libs/bookings/src/test/invite-visitor-form.component.spec.ts index dd74bdba15..9e848d0980 100644 --- a/libs/bookings/src/test/invite-visitor-form.component.spec.ts +++ b/libs/bookings/src/test/invite-visitor-form.component.spec.ts @@ -13,8 +13,8 @@ import { inject, Injector, signal } from '@angular/core'; import { Booking, OrganisationService, User } from '@placeos/common'; import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockModule, MockProvider, MockService } from 'ng-mocks'; +import { generateBookingForm } from '../lib/booking-form.model'; import { BookingFormService } from '../lib/booking-form.service'; -import { generateBookingForm } from '../lib/booking.utilities'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatCheckboxModule } from '@angular/material/checkbox'; diff --git a/libs/bookings/src/test/locker-filters.component.spec.ts b/libs/bookings/src/test/locker-filters.component.spec.ts index 8a41182e28..3fead9e360 100644 --- a/libs/bookings/src/test/locker-filters.component.spec.ts +++ b/libs/bookings/src/test/locker-filters.component.spec.ts @@ -16,8 +16,8 @@ import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle import { DateFieldComponent } from 'libs/form-fields/src/lib/date-field.component'; import { DurationFieldComponent } from 'libs/form-fields/src/lib/duration-field.component'; import { TimeFieldComponent } from 'libs/form-fields/src/lib/time-field.component'; +import { generateBookingForm } from '../lib/booking-form.model'; import { BookingFormService } from '../lib/booking-form.service'; -import { generateBookingForm } from '../lib/booking.utilities'; import { LockerFiltersComponent } from '../lib/locker-select-modal/locker-filters.component'; describe('LockerFiltersComponent', () => { diff --git a/libs/bookings/src/test/parking-list.component.spec.ts b/libs/bookings/src/test/parking-list.component.spec.ts index 4680e4af2c..b449e71bba 100644 --- a/libs/bookings/src/test/parking-list.component.spec.ts +++ b/libs/bookings/src/test/parking-list.component.spec.ts @@ -11,7 +11,8 @@ import { import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { BookingAsset, BookingFormService } from '../lib/booking-form.service'; +import { BookingFormService } from '../lib/booking-form.service'; +import { BookingAsset } from '../lib/booking-form.types'; import { ParkingListComponent } from '../lib/parking-select-modal/parking-list.component'; const asset = (over: Partial = {}): BookingAsset => diff --git a/libs/bookings/src/test/parking-map.component.spec.ts b/libs/bookings/src/test/parking-map.component.spec.ts index 4937a40657..fe85e99630 100644 --- a/libs/bookings/src/test/parking-map.component.spec.ts +++ b/libs/bookings/src/test/parking-map.component.spec.ts @@ -9,10 +9,8 @@ import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map.component'; -import { - BookingAsset, - BookingFormService, -} from '../lib/booking-form.service'; +import { BookingFormService } from '../lib/booking-form.service'; +import { BookingAsset } from '../lib/booking-form.types'; import { ParkingMapComponent } from '../lib/parking-select-modal/parking-map.component'; const asset = (over: Partial = {}): BookingAsset => diff --git a/libs/bookings/src/test/parking-select-modal.component.spec.ts b/libs/bookings/src/test/parking-select-modal.component.spec.ts index 3901ac3367..931a215528 100644 --- a/libs/bookings/src/test/parking-select-modal.component.spec.ts +++ b/libs/bookings/src/test/parking-select-modal.component.spec.ts @@ -11,7 +11,8 @@ import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { BookingAsset, BookingFormService } from '../lib/booking-form.service'; +import { BookingFormService } from '../lib/booking-form.service'; +import { BookingAsset } from '../lib/booking-form.types'; import { ParkingDetailsComponent } from '../lib/parking-select-modal/parking-details.component'; import { ParkingFiltersDisplayComponent } from '../lib/parking-select-modal/parking-filters-display.component'; import { ParkingFiltersComponent } from '../lib/parking-select-modal/parking-filters.component'; diff --git a/libs/bookings/src/test/visitor-invite-form.component.spec.ts b/libs/bookings/src/test/visitor-invite-form.component.spec.ts index c10b84b5c4..0a833db400 100644 --- a/libs/bookings/src/test/visitor-invite-form.component.spec.ts +++ b/libs/bookings/src/test/visitor-invite-form.component.spec.ts @@ -19,8 +19,8 @@ import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockModule, MockProvider, MockService } from 'ng-mocks'; import type { Mock } from 'vitest'; +import { generateBookingForm } from '../lib/booking-form.model'; import { BookingFormService } from '../lib/booking-form.service'; -import { generateBookingForm } from '../lib/booking.utilities'; import { VisitorInviteFormComponent } from '../lib/visitor-invite-form.component'; describe('VisitorInviteFormComponent', () => { diff --git a/libs/catering/src/index.ts b/libs/catering/src/index.ts index e78fd87df0..dbda52c244 100644 --- a/libs/catering/src/index.ts +++ b/libs/catering/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/catering-kitchen-board.component'; export * from './lib/catering-list-field.component'; export * from './lib/catering-menu.component'; export * from './lib/catering-order-alerts.service'; +export * from './lib/catering-order-details'; export * from './lib/catering-order-dockets.component'; export * from './lib/catering-order-list.component'; export * from './lib/catering-order-modal/catering-order-state.service'; diff --git a/libs/catering/src/lib/catering-kitchen-board.component.ts b/libs/catering/src/lib/catering-kitchen-board.component.ts index 6889a84339..53bd9fc2ee 100644 --- a/libs/catering/src/lib/catering-kitchen-board.component.ts +++ b/libs/catering/src/lib/catering-kitchen-board.component.ts @@ -19,11 +19,8 @@ import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { CateringOrderAlertsService } from './catering-order-alerts.service'; -import { - deliveryRuns, - orderLevelFinder, - orderLocation, -} from './catering-order-tools'; +import { orderLevelFinder, orderLocation } from './catering-order-details'; +import { deliveryRuns } from './catering-order-tools'; import { CateringOrdersService } from './catering-orders.service'; import { nextOrderStatus, orderUrgency, statusList } from './catering.vars'; diff --git a/libs/catering/src/lib/catering-order-details.ts b/libs/catering/src/lib/catering-order-details.ts new file mode 100644 index 0000000000..f42a600da2 --- /dev/null +++ b/libs/catering/src/lib/catering-order-details.ts @@ -0,0 +1,59 @@ +import type { OrganisationService, Space } from '@placeos/common'; +import { CateringOrder } from '@placeos/common'; +import { SpacePipe } from 'libs/events/src/lib/space.pipe'; + +// Display details of one catering order: where it goes, who it is for and +// what is in it. Operations on lists of orders are in catering-order-tools. + +const SPACE_PIPE = new SpacePipe(); + +/** Room name or location text of an order */ +export function orderLocation(order: CateringOrder) { + const space = + (order as CateringOrder & { space?: ReturnType }) + .space || + order.event?.system || + SPACE_PIPE.get( + order.system_id || order.event?.extension_data.system_id, + ); + return order.event?.location || space?.display_name || space?.name || ''; +} + +/** Name or email of the host of an order */ +export function orderHost(order: CateringOrder) { + return ( + order.event?.organiser?.name || + order.event?.host || + order.event?.organiser?.email || + '' + ); +} + +/** Items of an order as text, e.g. "2× Coffee (Oat milk); 1× Muffin" */ +export function orderItemsText(order: CateringOrder) { + return order.items + .map((item) => { + const options = item.option_list.map((o) => o.name).join(', '); + return `${item.quantity}× ${item.name}${options ? ` (${options})` : ''}`; + }) + .join('; '); +} + +/** + * Make a function that finds the level of an order from its room. + * @param org Organisation data with the list of levels + */ +export function orderLevelFinder( + org: Pick, +) { + return (order: CateringOrder) => { + const space = + (order as CateringOrder & { space?: Space }).space || + order.event?.system; + const level = + org.levelWithID([...(space?.zones || [])]) || + (space as Partial)?.level; + if (!level?.id) return null; + return { id: level.id, name: level.display_name || level.name }; + }; +} diff --git a/libs/catering/src/lib/catering-order-dockets.component.ts b/libs/catering/src/lib/catering-order-dockets.component.ts index 559d5b9c36..11522a2663 100644 --- a/libs/catering/src/lib/catering-order-dockets.component.ts +++ b/libs/catering/src/lib/catering-order-dockets.component.ts @@ -3,7 +3,7 @@ import { Component, inject, Injectable, signal } from '@angular/core'; import { CateringOrder, SettingsService } from '@placeos/common'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { orderHost, orderLocation } from './catering-order-tools'; +import { orderHost, orderLocation } from './catering-order-details'; /** * Prints order dockets. Needs a `catering-order-dockets` element in the app diff --git a/libs/catering/src/lib/catering-order-tools.ts b/libs/catering/src/lib/catering-order-tools.ts index fc614b8c43..cd64f9ddfc 100644 --- a/libs/catering/src/lib/catering-order-tools.ts +++ b/libs/catering/src/lib/catering-order-tools.ts @@ -1,12 +1,16 @@ -import type { OrganisationService, Space } from '@placeos/common'; import { CateringOrder } from '@placeos/common'; import { format } from 'date-fns'; import { i18n } from 'libs/common/src/lib/locale.service'; -import { SpacePipe } from 'libs/events/src/lib/space.pipe'; +import { + orderHost, + orderItemsText, + orderLocation, +} from './catering-order-details'; import { statusList } from './catering.vars'; -const SPACE_PIPE = new SpacePipe(); +// Operations on lists of catering orders. Display details of one order are +// in catering-order-details. /** Total amount of one menu item in a list of orders */ export interface PrepSummaryItem { @@ -34,38 +38,6 @@ export interface OrderChanges { cancelled: CateringOrder[]; } -/** Room name or location text of an order */ -export function orderLocation(order: CateringOrder) { - const space = - (order as CateringOrder & { space?: ReturnType }) - .space || - order.event?.system || - SPACE_PIPE.get( - order.system_id || order.event?.extension_data.system_id, - ); - return order.event?.location || space?.display_name || space?.name || ''; -} - -/** Name or email of the host of an order */ -export function orderHost(order: CateringOrder) { - return ( - order.event?.organiser?.name || - order.event?.host || - order.event?.organiser?.email || - '' - ); -} - -/** Items of an order as text, e.g. "2× Coffee (Oat milk); 1× Muffin" */ -export function orderItemsText(order: CateringOrder) { - return order.items - .map((item) => { - const options = item.option_list.map((o) => o.name).join(', '); - return `${item.quantity}× ${item.name}${options ? ` (${options})` : ''}`; - }) - .join('; '); -} - /** * Add up the items in the orders so the kitchen can plan the day. * Items that need the most work are first. @@ -173,22 +145,3 @@ export function ordersToCsvRows(orders: readonly CateringOrder[]) { [i18n('FORM.NOTES')]: order.notes, })); } - -/** - * Make a function that finds the level of an order from its room. - * @param org Organisation data with the list of levels - */ -export function orderLevelFinder( - org: Pick, -) { - return (order: CateringOrder) => { - const space = - (order as CateringOrder & { space?: Space }).space || - order.event?.system; - const level = - org.levelWithID([...(space?.zones || [])]) || - (space as Partial)?.level; - if (!level?.id) return null; - return { id: level.id, name: level.display_name || level.name }; - }; -} diff --git a/libs/catering/src/lib/catering-orders.service.ts b/libs/catering/src/lib/catering-orders.service.ts index 0c591e54dd..0dd59f150f 100644 --- a/libs/catering/src/lib/catering-orders.service.ts +++ b/libs/catering/src/lib/catering-orders.service.ts @@ -36,12 +36,8 @@ import { import { SpacePipe } from 'libs/events/src/lib/space.pipe'; import { newCalendarEventFromBooking } from 'libs/events/src/lib/utilities'; import { Subject } from 'rxjs'; -import { - diffOrders, - OrderChanges, - orderHost, - orderLocation, -} from './catering-order-tools'; +import { orderHost, orderLocation } from './catering-order-details'; +import { diffOrders, OrderChanges } from './catering-order-tools'; import { CateringOrderStatus } from './catering.interfaces'; import { CateringStatusFilter, diff --git a/libs/catering/src/lib/catering-prep-summary.component.ts b/libs/catering/src/lib/catering-prep-summary.component.ts index dccfaecf60..0b6001728f 100644 --- a/libs/catering/src/lib/catering-prep-summary.component.ts +++ b/libs/catering/src/lib/catering-prep-summary.component.ts @@ -11,12 +11,8 @@ import { } from '@placeos/common'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { - deliveryRuns, - orderLevelFinder, - orderLocation, - prepSummary, -} from './catering-order-tools'; +import { orderLevelFinder, orderLocation } from './catering-order-details'; +import { deliveryRuns, prepSummary } from './catering-order-tools'; import { CateringOrdersService } from './catering-orders.service'; /** diff --git a/libs/common/src/lib/types/booking.class.ts b/libs/common/src/lib/types/booking.class.ts index ec52cab449..0afce1fea9 100644 --- a/libs/common/src/lib/types/booking.class.ts +++ b/libs/common/src/lib/types/booking.class.ts @@ -79,6 +79,130 @@ export interface LinkedCalendarEvent { ical_uid?: string; } +const DAY_MINUTES = 24 * 60; + +/** Start, length and end of a booking in milliseconds and minutes */ +interface BookingWindow { + date: number; + duration: number; + date_end: number; +} + +/** Display name of the booked asset. Visitor bookings prefer the visitor name. */ +function resolveAssetName( + data: Partial, + booking_type: string, +): string { + const ext = data.extension_data; + const name = data.asset_name || ext?.asset_name || ext?.name; + if (booking_type === 'visitor') { + return ext?.visitor_name || name || data.asset_id || ''; + } + return name || data.description || data.asset_id || ''; +} + +/** + * Start and end of the booking in unix seconds. + * Without a start, the booking starts on the next 5 minute mark after now + 5 minutes. + */ +function resolveUnixWindow(data: Partial): { + start: number; + end: number; +} { + const duration = data.duration || 60; + // `date`/`duration` are the source of truth for the booking window. + // Both ends must derive from the same source so we never pair a fresh + // start (from `date`) with a stale `booking_end` (which can invert the + // window and send booking_end before booking_start to the API). + if (data.date) { + const start = Math.floor(data.date / 1000); + return { start, end: start + duration * 60 }; + } + const start = + data.booking_start || + getUnixTime( + roundToNearestMinutes(addMinutes(Date.now(), 5), { + nearestTo: 5, + }), + ); + return { + start, + end: + data.booking_end || getUnixTime(addMinutes(start * 1000, duration)), + }; +} + +/** Booking window in milliseconds from `date`/`duration`, else from the unix window */ +function toBookingWindow( + data: Partial, + start: number, + end: number, +): BookingWindow { + const date = data.date || start * 1000 || Date.now(); + const duration = + data.duration || + Math.abs(differenceInMinutes(start * 1000, end * 1000)) || + 60; + return { + date, + duration, + date_end: end * 1000 || date + duration * 60 * 1000, + }; +} + +/** Whether the raw data flags the booking as a custom all day booking */ +function isCustomAllDay(data: Partial): boolean { + return !!( + data.extension_data?.custom_all_day || + (data as Record).custom_all_day + ); +} + +/** + * Snap the window of an all day booking to whole days in `timezone`. + * Only applies when the data has no explicit length, or the length is whole days. + */ +function snapToWholeDays( + data: Partial, + window: BookingWindow, + timezone: string, +): BookingWindow { + const has_length = !!(data.duration || data.date_end || data.booking_end); + if (has_length && window.duration % DAY_MINUTES !== 0) return window; + const date = startOfDayInTimezone(window.date, timezone); + return { + date, + duration: has_length + ? Math.max(1, window.duration - 1) + : DAY_MINUTES - 1, + date_end: endOfDayInTimezone(date, timezone), + }; +} + +/** Booking status from local flags first, then the status sent by the server */ +function resolveStatus( + booking: Pick, + status: Booking['status'] | undefined, +): Booking['status'] { + if (booking.deleted || status === 'cancelled') return 'cancelled'; + if (booking.rejected || status === 'declined') return 'declined'; + if (booking.has_ended) return 'ended'; + if (booking.approved || status === 'approved') return 'approved'; + return 'tentative'; +} + +/** Copy truthy fields that the booking does not define into its extension data */ +function copyUnknownFields( + booking: Booking, + data: Partial, +): void { + for (const key in data) { + if (!(key in booking) && !IGNORE_EXT_KEYS.includes(key) && data[key]) { + booking.extension_data[key] = data[key]; + } + } +} + /** General purpose booking class */ export class Booking { /** Unique Identifier of the object */ @@ -237,64 +361,34 @@ export class Booking { } constructor(data: Partial = {}) { - const custom_all_day = !!( - data.extension_data?.custom_all_day || (data as any).custom_all_day - ); this.id = data.id || ''; this.parent_id = data.parent_id || ''; this.asset_id = data.asset_id || ''; this.asset_ids = data.asset_ids || [data.asset_id].filter((_) => _); - const booking_type = data.booking_type || data.type || ' '; - this.asset_name = - booking_type === 'visitor' - ? data.extension_data?.visitor_name || - data.asset_name || - data.extension_data?.asset_name || - data.extension_data?.name || - data.asset_id || - '' - : data.asset_name || - data.extension_data?.asset_name || - data.extension_data?.name || - data.description || - data.asset_id || - ''; + this.asset_name = resolveAssetName( + data, + data.booking_type || data.type || ' ', + ); this.zones = data.zones || []; - // `date`/`duration` are the source of truth for the booking window. - // Both ends must derive from the same source so we never pair a fresh - // start (from `date`) with a stale `booking_end` (which can invert the - // window and send booking_end before booking_start to the API). - const has_date = !!data.date; - this.booking_start = has_date - ? Math.floor(data.date / 1000) - : data.booking_start || - getUnixTime( - roundToNearestMinutes(addMinutes(Date.now(), 5), { - nearestTo: 5, - }), - ); - this.booking_end = has_date - ? this.booking_start + (data.duration || 60) * 60 - : data.booking_end || - getUnixTime( - addMinutes(this.booking_start * 1000, data.duration || 60), - ); + const { start, end } = resolveUnixWindow(data); + this.booking_start = start; + this.booking_end = end; this.booking_type = data.booking_type || ' '; this.type = data.type || data.booking_type || 'booking'; - this.date = data.date || this.booking_start * 1000 || Date.now(); - this.duration = - data.duration || - Math.abs( - differenceInMinutes( - this.booking_start * 1000, - this.booking_end * 1000, - ), - ) || - 60; - this.date_end = - this.booking_end * 1000 || this.date + this.duration * 60 * 1000; - this.timezone = + const timezone = data.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const base_window = toBookingWindow(data, start, end); + const all_day = + !!data.all_day || + isCustomAllDay(data) || + base_window.duration >= DAY_MINUTES; + const window = all_day + ? snapToWholeDays(data, base_window, timezone) + : base_window; + this.date = window.date; + this.duration = window.duration; + this.date_end = window.date_end; + this.timezone = timezone; this.user_email = data.user_email || ''; this.user_id = data.user_id || ''; this.user_name = data.user_name || ''; @@ -317,55 +411,22 @@ export class Booking { this.extension_data = data.extension_data || {}; this.access = !!data.extension_data?.access; this.event_id = data.event_id; - this.permission = (data.permission || 'PRIVATE').toUpperCase() as any; + this.permission = ( + data.permission || 'PRIVATE' + ).toUpperCase() as Booking['permission']; this.attendees = data.attendees || data.guests || data.members || []; this.tags = data.tags || data.extension_data?.tags || []; this.images = data.images || []; - this.all_day = - !!data.all_day || custom_all_day || this.duration >= 24 * 60; + this.all_day = all_day; this.induction = data.induction || undefined; this.created_at = data.created_at || Date.now(); this.history = data.history || []; - if (this.all_day) { - if (!data.duration && !data.date_end && !data.booking_end) { - (this as any).date = startOfDayInTimezone( - this.date, - this.timezone, - ); - (this as any).duration = 24 * 60 - 1; - (this as any).date_end = endOfDayInTimezone( - this.date, - this.timezone, - ); - } else if (this.duration % (24 * 60) === 0) { - (this as any).date = startOfDayInTimezone( - this.date, - this.timezone, - ); - (this as any).duration = Math.max(1, this.duration - 1); - (this as any).date_end = endOfDayInTimezone( - this.date, - this.timezone, - ); - } - } this.checked_out_at = data.checked_out_at; this.checked_in_at = data.checked_in_at; this.linked_event = data.linked_event || null; this.linked_bookings = data.linked_bookings || []; this.linked_parent_booking = data.linked_parent_booking || null; - this.images = data.images || []; - this.status = - this.deleted || data.status === 'cancelled' - ? 'cancelled' - : this.rejected || data.status === 'declined' - ? 'declined' - : this.checked_out_at > 0 || - isAfter(Date.now(), this.date_end) - ? 'ended' - : this.approved || data.status === 'approved' - ? 'approved' - : 'tentative'; + this.status = resolveStatus(this, data.status); this.process_state = data.process_state || 'pending'; this.recurrence_type = data.recurrence_type || 'none'; @@ -375,12 +436,7 @@ export class Booking { this.recurrence_end = data.recurrence_end; this.instance = data.instance; - for (const key in data) { - if (!(key in this) && !IGNORE_EXT_KEYS.includes(key) && data[key]) { - this.extension_data[key] = - data[key] || this.extension_data[key]; - } - } + copyUnknownFields(this, data); this.extension_data.assets = (this.extension_data.assets || []).map( (i) => new AssetRequest({ ...i, event: this, date: this.date } as any), diff --git a/libs/events/src/lib/event-form-changes.ts b/libs/events/src/lib/event-form-changes.ts new file mode 100644 index 0000000000..550efcddcc --- /dev/null +++ b/libs/events/src/lib/event-form-changes.ts @@ -0,0 +1,82 @@ +import type { EventFormValue } from './event-form'; + +/** Form fields that are derived or need semantic comparison below. */ +const IGNORED_DETAIL_FIELDS = [ + 'attendees', + 'body', + 'system', + 'date_end', + 'organiser', + 'recurrence', + 'resources', +]; + +/** Serialise an event body so that editor-only markup changes compare equal. */ +function normaliseEventBody(body: string) { + const template = document.createElement('template'); + template.innerHTML = body || ''; + const serialise = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent || '').replace(/\u200b/g, ''); + } + if (node.nodeType !== Node.ELEMENT_NODE) return ''; + const element = node as Element; + if (element.tagName === 'BR') return '\n'; + const content = [...element.childNodes].map(serialise).join(''); + if (element.tagName === 'DIV' || element.tagName === 'P') { + return `\n${content}\n`; + } + const tag = element.tagName.toLowerCase(); + const attributes = [...element.attributes] + .sort((a, b) => a.name.localeCompare(b.name)) + .map(({ name, value }) => ` ${name}="${value}"`) + .join(''); + return `<${tag}${attributes}>${content}`; + }; + return [...template.content.childNodes] + .map(serialise) + .join('') + .replace(/[ \t]+\n|\n[ \t]+/g, '\n') + .replace(/\n+/g, '\n') + .trim(); +} + +/** Lower-cased emails of the attendees in an event form value. */ +export function attendeeEmails(value: EventFormValue): string[] { + return value.attendees.map((_) => (_.email || _).toLowerCase()); +} + +/** + * Serialise every event detail except the attendee list. Two values with the + * same key differ only in their attendees. + */ +export function eventDetailsKey(value: EventFormValue): string { + const details = Object.entries(value).filter( + ([key]) => !IGNORED_DETAIL_FIELDS.includes(key), + ); + const recurrence = value.recurrence; + details.push(['body', normaliseEventBody(value.body)]); + details.push(['host_email', value.organiser?.email || '']); + details.push([ + 'recurrence', + recurrence?.pattern && recurrence?._pattern !== 'none' + ? [ + recurrence.pattern, + recurrence.interval || 1, + [...(recurrence.days_of_week || [])].sort(), + recurrence.nth_of_month || null, + recurrence.start || null, + recurrence.end || null, + recurrence.occurrences || null, + ] + : null, + ]); + details.push([ + 'space_ids', + (value.resources || []) + .map((_) => (_.email || _.id || '').toLowerCase()) + .sort(), + ]); + details.sort(([a], [b]) => (a > b ? 1 : -1)); + return JSON.stringify(details); +} diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index 3e7215e2c4..da802a8182 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -13,27 +13,21 @@ import { MatDialog } from '@angular/material/dialog'; import { Event, NavigationEnd, Router } from '@angular/router'; import { AsyncHandler, - BookingClash, BookingRuleset, CalendarEvent, currentUser, currentUserCanApprove, currentUserIsLoaded, currentUserLoaded, - DEFAULT_SETTINGS, filterResourcesFromRules, firstValueWhere, flatten, getAllDayTimeRange, getInvalidSignalFields, - getItemWithKeys, - getTimeInTimezone, i18n, isEmptyUser, - isWithinBookableHours, notifyWarn, onFieldChange, - rulesForResource, sameDayInTimezone, setDefaultCreator, SettingsService, @@ -55,23 +49,35 @@ import { removeBooking, saveBooking, } from 'libs/bookings/src/lib/bookings.fn'; -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 { CalendarService } from './calendar.service'; +import { + eventFormValue, + generateEventForm, + type EventFormValue, +} from './event-form'; +import { attendeeEmails, eventDetailsKey } from './event-form-changes'; import { EventLinkModalComponent } from './event-link-modal.component'; import { - findEventClashes, + checkBuildingBookableHours, + checkRecurringClashes, + checkSpaceRules, + checkSpacesAvailable, + errorMessage, + eventSaveQuery, + isPermissionError, + spaceNames, +} from './event-save.fn'; +import { querySpaceAvailability, removeEvent, saveEvent, + type CalendarEventShowParams, } from './events.fn'; import { - eventFormValue, - generateEventForm, multipleSpacesEnabled, newCalendarEventFromBooking, - type EventFormValue, } from './utilities'; const BOOKING_URLS = [ @@ -83,46 +89,6 @@ const BOOKING_URLS = [ 'upcoming', ]; -/** Form fields that are derived or need semantic comparison below. */ -const IGNORED_DETAIL_FIELDS = [ - 'attendees', - 'body', - 'system', - 'date_end', - 'organiser', - 'recurrence', - 'resources', -]; - -function normaliseEventBody(body: string) { - const template = document.createElement('template'); - template.innerHTML = body || ''; - const serialise = (node: Node): string => { - if (node.nodeType === Node.TEXT_NODE) { - return (node.textContent || '').replace(/\u200b/g, ''); - } - if (node.nodeType !== Node.ELEMENT_NODE) return ''; - const element = node as Element; - if (element.tagName === 'BR') return '\n'; - const content = [...element.childNodes].map(serialise).join(''); - if (element.tagName === 'DIV' || element.tagName === 'P') { - return `\n${content}\n`; - } - const tag = element.tagName.toLowerCase(); - const attributes = [...element.attributes] - .sort((a, b) => a.name.localeCompare(b.name)) - .map(({ name, value }) => ` ${name}="${value}"`) - .join(''); - return `<${tag}${attributes}>${content}`; - }; - return [...template.content.childNodes] - .map(serialise) - .join('') - .replace(/[ \t]+\n|\n[ \t]+/g, '\n') - .replace(/\n+/g, '\n') - .trim(); -} - enum Tags { Availability = 'AVAILABILITY', BookingRules = 'BOOKING_RULES', @@ -206,13 +172,11 @@ export class EventFormService extends AsyncHandler { public readonly can_notify_new_attendees_only = computed(() => { const model = this._model(); if (!model.id) return false; - const attendee_emails = model.attendees.map((_) => - (_.email || _).toLowerCase(), - ); + const attendee_emails = attendeeEmails(model); return ( this._initial_attendees.every((_) => attendee_emails.includes(_)) && attendee_emails.some((_) => !this._initial_attendees.includes(_)) && - this._eventDetails(model) === this._initial_event_details + eventDetailsKey(model) === this._initial_event_details ); }); @@ -982,16 +946,7 @@ export class EventFormService extends AsyncHandler { throw i18n('CALENDAR_EVENT.SPACE_EXTERNALS_ERROR'); } // Handle setup and breakdown times - const default_oflow = this._overflow(); - let [setup, breakdown] = [ - this._model().setup_time || default_oflow.setup, - this._model().breakdown_time || default_oflow.breakdown, - ]; - for (const space of spaces) { - const overflow = this._overflow(space.id); - setup = Math.max(overflow.setup || 0, setup); - breakdown = Math.max(overflow.breakdown || 0, breakdown); - } + const { setup, breakdown } = this._overflowTimes(spaces); this._model.update((m) => ({ ...m, setup_time: setup, @@ -1003,36 +958,11 @@ export class EventFormService extends AsyncHandler { order.charge_code = this._model().catering_charge_code; } // Perform Booking - const query: any = event.id - ? { - system_id: - event?.resources[0]?.id || - event?.system?.id || - spaces[0]?.id, - } - : {}; - if (notify_new_attendees_only) - query.notify_existing_attendees = false; - const user_email = currentUser()?.email?.toLowerCase() || ''; - const source_calendar = - event.calendar || - event.host || - event.creator || - raw_value.calendar || - raw_value.creator; - const target_calendar = raw_value.host || raw_value.creator; - const query_calendar = event.id ? source_calendar : target_calendar; - const owner_fields = event.id - ? [event.host, event.creator, event.calendar] - : [raw_value.host, raw_value.creator, raw_value.calendar]; - const is_owner = owner_fields.some( - (_) => _?.toLowerCase?.() === user_email, - ); - if ( - ((is_owner && !ignore_owner) || force_calendar) && - query_calendar - ) - query.calendar = query_calendar; + const query = eventSaveQuery(event, raw_value, spaces, { + notify_new_attendees_only, + ignore_owner, + force_calendar, + }); const processed_assets = (this._model().assets || []).map((_) => new AssetRequest(_).toJSON(), ); @@ -1091,15 +1021,12 @@ export class EventFormService extends AsyncHandler { resources: booked_resources, }); if (failed_resources.length) { - const names = failed_resources - .map((_) => _.display_name || _.name || _.email) - .join(', '); notifyWarn( i18n( failed_resources.length > 1 ? 'CALENDAR_EVENT.SPACES_UNAVAILABLE' : 'CALENDAR_EVENT.SPACE_UNAVAILABLE', - { spaces: names }, + { spaces: spaceNames(failed_resources) }, ), ); } @@ -1193,28 +1120,11 @@ export class EventFormService extends AsyncHandler { return created_event; } catch (e) { this.removeLoadingTag(Tags.PostBooking); - if (this._isPermissionError(e)) this._clearSavedHostChange(); + if (isPermissionError(e)) this._clearSavedHostChange(); throw e; } } - private _isPermissionError(error: any) { - const status = error?.status || error?.error?.status; - if (status === 403) return true; - const message = this._errorMessage(error).toLowerCase(); - return /forbidden|permission|authori[sz]ed|not permitted/.test(message); - } - - private _errorMessage(error: any) { - if (typeof error === 'string') return error; - if (error instanceof Error && error.message) return error.message; - if (typeof error?.error === 'string') return error.error; - if (typeof error?.message === 'string') return error.message; - if (typeof error?.error?.message === 'string') - return error.error.message; - return ''; - } - private _clearSavedHostChange() { const user = currentUser(); if (!user) return; @@ -1234,60 +1144,18 @@ export class EventFormService extends AsyncHandler { ); } - private async _checkResourcesAvailable( + /** Throw when any of the spaces is booked for the period. */ + private _checkResourcesAvailable( spaces: Space[], date: number, duration: number, ignore?: string, ) { - if (!spaces?.length) return true; - const event = this._event(); - const id_list = spaces.map((_) => _.id); - const response = await (this.book_internal - ? queryResourceAvailability(id_list, date, duration, ignore) - : querySpaceAvailability( - id_list, - date, - duration, - event?.resources[0]?.id || - event?.system?.id || - event?.id || - undefined, - undefined, - [event?.date, event?.duration], - )); - const unavailable = spaces.filter((_, i) => !response[i]); - if (unavailable.length) { - const names = unavailable - .map((_) => _.display_name || _.name || _.email) - .join(', '); - throw i18n( - unavailable.length > 1 - ? 'CALENDAR_EVENT.SPACES_UNAVAILABLE' - : 'CALENDAR_EVENT.SPACE_UNAVAILABLE', - { spaces: names }, - ); - } - return true; - } - - /** Resolve an app setting against one building's override stack. */ - private _buildingSetting( - key: string, - building: { id: string; parent_id?: string }, - ): T | undefined { - const keys = key.split('.'); - const override_keys = keys[0] === 'app' ? keys.slice(1) : keys; - const overrides = [ - this._org.buildingSettings(building.id), - this._org.regionSettings(building.parent_id), - ...(this._org.settings || []), - ]; - for (const override of overrides) { - const value = getItemWithKeys(override_keys, override); - if (value != null) return value as T; - } - return getItemWithKeys(keys, DEFAULT_SETTINGS) as T | undefined; + return checkSpacesAvailable(spaces, date, duration, { + ignore, + event: this._event(), + book_internal: this.book_internal, + }); } private _resolveResourceResponses( @@ -1331,54 +1199,23 @@ export class EventFormService extends AsyncHandler { } /** Check the event instant against every selected building's local hours. */ - private async _checkBuildingBookableHours( + private _checkBuildingBookableHours( spaces: Space[], date: number, date_end: number, organiser_timezone: string, ) { - const buildings = await this._org.loadBuildingsForZones( - spaces.map((space) => space.zones), - ); - await Promise.all( - buildings.map((building) => this._org.loadBuildingData(building)), + return checkBuildingBookableHours( + this._org, + this._settings, + spaces, + date, + date_end, + organiser_timezone, ); - const policies = buildings.length - ? buildings.map((building) => ({ - hours: this._buildingSetting<{ - start: number; - end: number; - }>('app.events.bookable_hours', building), - timezone: building.timezone || organiser_timezone, - })) - : [ - { - hours: this._settings.get<{ - start: number; - end: number; - }>('app.events.bookable_hours'), - timezone: organiser_timezone, - }, - ]; - for (const { hours, timezone } of policies) { - if (!hours) continue; - const { hours: end_hour, minutes: end_minute } = getTimeInTimezone( - date_end, - timezone, - ); - const end_minutes = end_hour * 60 + end_minute; - const end_is_valid = - end_minutes >= hours.start * 60 && - end_minutes <= hours.end * 60; - if ( - !isWithinBookableHours(date, hours, timezone) || - !end_is_valid - ) { - throw i18n('FORM.BOOKABLE_HOURS_ERROR'); - } - } } + /** Throw when the booking rules hide any of the spaces from the host. */ private async _checkResourceRules( spaces: Space[], date: number, @@ -1387,48 +1224,12 @@ export class EventFormService extends AsyncHandler { ) { const user = await this._bookingRulesHost(host); await this._whenSettled(this._booking_rules_resource); - const rules = { ...this.booking_rules() }; - const buildings = await this._org.loadBuildingsForZones( - spaces.map((space) => space.zones), + return checkSpaceRules( + this._org, + spaces, + { date, duration, host: new User(user) }, + this.booking_rules(), ); - // The booking panel does not eagerly load zone metadata, so the - // reactive rules resource may still be empty when a booking is - // submitted. Fetch any missing building rules on demand so they are - // always enforced regardless of which app submitted the booking. - for (const space of spaces) { - const bld = buildings.find((b) => space.zones.includes(b.id)); - if (!bld || rules[bld.id]) continue; - const metadata = await showMetadata( - bld.id, - 'room_booking_rules', - ).catch(() => ({ details: [] }) as any); - rules[bld.id] = - metadata.details instanceof Array ? metadata.details : []; - } - const space_rules = spaces.map((space) => { - const bld = buildings.find((b) => space.zones.includes(b.id)); - return rulesForResource( - { - date, - duration, - host: new User(user), - resource: space, - }, - rules[bld?.id], - ); - }); - const hidden = spaces.filter((_, i) => space_rules[i]?.hidden); - if (hidden.length) { - const names = hidden - .map((_) => _.display_name || _.name || _.email) - .join(', '); - throw i18n( - 'CALENDAR_EVENT.SPACE_BOOKING_RULES_HIDDEN', - { spaces: names }, - hidden.length, - ); - } - return true; } private async _bookingRulesHost(host: string) { @@ -1446,65 +1247,29 @@ export class EventFormService extends AsyncHandler { .catch(() => ({ email: host, name: host })); } - /** - * Check for clashing events in a recurring event series - * @param event The calendar event to check for clashes - * @returns true if no clashes or user confirmed to continue - * @throws Error if first instance clashes or clashes not allowed - */ - private async _checkRecurringClashes( - event: CalendarEvent, - ): Promise { - if (!event.recurring) { - return true; - } - - const clashes = (await findEventClashes(event, { - include_clash_time: true, - })) as BookingClash[]; - - if (!clashes?.length) { - return true; - } - - const sorted_clashes = [...clashes].sort( - (a, b) => a.booking_start - b.booking_start, - ); - - const event_start_unix = Math.floor(event.date / 1000); - const first_clash = sorted_clashes[0]; - const is_first_instance_clash = - first_clash.booking_start === event_start_unix; - - if (is_first_instance_clash) { - throw i18n('CALENDAR_EVENT.FIRST_INSTANCE_CLASH'); - } - - const allow_clashes = - this._settings.get('app.events.allow_recurring_instance_clashes') ?? - false; - - if (!allow_clashes) { - throw i18n('CALENDAR_EVENT.RECURRING_CLASHES_NOT_ALLOWED', { - count: clashes.length, - }); - } - - const result = await openRecurringClashModal( - { clashes: sorted_clashes }, - this._dialog, - ); + /** Check for clashing events in a recurring event series. */ + private _checkRecurringClashes(event: CalendarEvent) { + return checkRecurringClashes(event, this._settings, this._dialog); + } - if (result?.reason !== 'done') { - throw 'User cancelled'; + /** Largest setup and breakdown times of the form, defaults and spaces. */ + private _overflowTimes(spaces: Space[]) { + const default_oflow = this._overflow(); + let [setup, breakdown] = [ + this._model().setup_time || default_oflow.setup, + this._model().breakdown_time || default_oflow.breakdown, + ]; + for (const space of spaces) { + const overflow = this._overflow(space.id); + setup = Math.max(overflow.setup || 0, setup); + breakdown = Math.max(overflow.breakdown || 0, breakdown); } - - return true; + return { setup, breakdown }; } private async _performBooking( event: CalendarEvent, - query: Record, + query: CalendarEventShowParams, ) { this._updateVisitorList(event.attendees); const old_system = @@ -1536,42 +1301,10 @@ export class EventFormService extends AsyncHandler { : saveEvent(event, query); } + /** Snapshot the loaded event to detect edits that only add attendees. */ private _setInitialEvent(value: EventFormValue) { - this._initial_attendees = value.attendees.map((_) => - (_.email || _).toLowerCase(), - ); - this._initial_event_details = this._eventDetails(value); - } - - private _eventDetails(value: EventFormValue) { - const details = Object.entries(value).filter( - ([key]) => !IGNORED_DETAIL_FIELDS.includes(key), - ); - const recurrence = value.recurrence; - details.push(['body', normaliseEventBody(value.body)]); - details.push(['host_email', (value.organiser as any)?.email || '']); - details.push([ - 'recurrence', - recurrence?.pattern && recurrence?._pattern !== 'none' - ? [ - recurrence.pattern, - recurrence.interval || 1, - [...(recurrence.days_of_week || [])].sort(), - recurrence.nth_of_month || null, - recurrence.start || null, - recurrence.end || null, - recurrence.occurrences || null, - ] - : null, - ]); - details.push([ - 'space_ids', - (value.resources || []) - .map((_: any) => (_.email || _.id || '').toLowerCase()) - .sort(), - ]); - details.sort(([a], [b]) => (a > b ? 1 : -1)); - return JSON.stringify(details); + this._initial_attendees = attendeeEmails(value); + this._initial_event_details = eventDetailsKey(value); } private async _removeBookingAfterError( @@ -1599,7 +1332,7 @@ export class EventFormService extends AsyncHandler { : i18n('CALENDAR_EVENT.ASSETS_ERROR'); } else if (assets) { throw i18n('CALENDAR_EVENT.ASSETS_PARTIAL_ERROR', { - error: this._errorMessage(e) || e, + error: errorMessage(e) || e, }); } this.removeLoadingTag(Tags.PostBooking); diff --git a/libs/events/src/lib/event-form.ts b/libs/events/src/lib/event-form.ts new file mode 100644 index 0000000000..cd30a96373 --- /dev/null +++ b/libs/events/src/lib/event-form.ts @@ -0,0 +1,282 @@ +import { signal, type Injector, type WritableSignal } from '@angular/core'; +import { + disabled, + form, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import { + CalendarEvent, + currentUser, + guardModelUndefinedWrites, + LOCAL_TIMEZONE, + onFieldChange, + SettingsService, + setupFormTimeSync, + User, + type FormTimeSyncHandle, + type SignalFormRef, +} from '@placeos/common'; +import { addMinutes, isAfter, startOfDay } from 'date-fns'; + +/** Raw value held by the event form model. */ +export interface EventFormValue { + id: string; + ical_uid: string; + host: string; + organiser: User; + creator: string; + calendar: string; + attendees: any[]; + resources: any[]; + title: string; + body: string; + private: boolean; + date: number; + duration: number; + all_day: boolean; + date_end: number; + recurring: boolean; + recurrence: any; + recurring_event_id: string; + master: any; + attachments: any; + catering: any[]; + catering_notes: string; + catering_charge_code: string; + setup_time: number; + breakdown_time: number; + assets: any[]; + visitor_type: any; + location: string; + visibility: string; + needs_space: boolean; + needs_parking: boolean; + event_type: string; + category: string; + tags: string[]; + update_master: boolean; + system: any; + attendance_type: string; + timezone: string; + shared_event: boolean; + view_access: string; + images: any[]; + featured: boolean; + meeting_provider: any; +} + +/** Build the raw event form value from a calendar event. */ +export function eventFormValue( + event: CalendarEvent = new CalendarEvent(), +): EventFormValue { + // Every field must be non-`undefined`: the signal-forms FieldTree only + // exposes a sub-field for keys whose value is defined, so an `undefined` + // seed would make `[formField]="form.x"` bind to nothing at runtime. + return { + id: event.id || '', + ical_uid: event.ical_uid || '', + host: + event.host || event.organiser?.email || currentUser()?.email || '', + organiser: event.organiser || ({ email: event.host || '' } as User), + creator: event.creator || currentUser()?.email || '', + calendar: event.calendar || '', + attendees: event.attendees || [], + resources: event.resources || [], + title: event.title || '', + body: event.body || '', + private: event.private ?? false, + date: event.date ?? 0, + duration: event.duration ?? 0, + all_day: event.all_day ?? false, + date_end: event.date_end ?? 0, + recurring: event.recurring ?? false, + recurrence: event.recurrence ?? null, + recurring_event_id: event.recurring_event_id || '', + master: event.master ?? null, + attachments: event.attachments ?? null, + catering: (event.extension_data?.catering as any) || [], + catering_notes: event.extension_data?.catering?.[0]?.notes || '', + catering_charge_code: + event.extension_data?.catering?.[0]?.charge_code || '', + setup_time: event.setup_time || 0, + breakdown_time: event.breakdown_time || 0, + assets: event.extension_data?.assets || [], + visitor_type: event.extension_data?.visitor_type ?? null, + location: event.location || '', + visibility: event.visibility || 'normal', + needs_space: true, + needs_parking: event.extension_data?.needs_parking || false, + event_type: event.extension_data?.event_type || '', + category: event.extension_data?.category || '', + tags: event.extension_data?.tags || [], + update_master: false, + system: event.system ?? null, + attendance_type: event.extension_data?.attendance_type || 'ONSITE', + timezone: event.timezone || LOCAL_TIMEZONE, + shared_event: event.extension_data?.shared_event || false, + view_access: event.extension_data?.view_access || 'OPEN', + images: event.extension_data?.images || [], + featured: event.extension_data?.featured || false, + meeting_provider: event.meeting_provider || null, + } as EventFormValue; +} + +/** FieldTree for the event form, with attached time-sync + lock handles. */ +export type EventForm = FieldTree & { + _time_sync?: FormTimeSyncHandle; + _lock_start_time?: WritableSignal; +}; + +/** Model, FieldTree and control handles returned by {@link generateEventForm}. */ +export type EventFormRef = SignalFormRef & { + /** Time-sync handle for runtime duration/bookable-hours reconfiguration. */ + time_sync: FormTimeSyncHandle; + /** Signal controlling whether the start time is locked (in-progress events). */ + lock_start_time: WritableSignal; +}; + +/** + * Create the signal form for an event, with validation, derived field sync + * and time sync attached. + * @param event Event used to seed the form value + * @param settings Settings used for conditional validation + * @param injector Injector for the reactive field listeners + */ +export function generateEventForm( + event: CalendarEvent = new CalendarEvent(), + settings?: SettingsService, + injector?: Injector, +): EventFormRef { + if (!event) event = new CalendarEvent(); + const lock_start_time = signal( + !!event.id && + (event.state === 'started' || event.state === 'in_progress'), + ); + const has_id = !!event.id; + const notes_required = () => + !!( + settings?.get('app.events.catering_notes_required') || + settings?.value('require_catering_notes') + ); + + const model: WritableSignal = signal(eventFormValue(event)); + + // Keep every key defined so signal-forms never drops a sub-field bound via + // `[formField]` (an undefined value triggers `this.field() is not a + // function`). Guards writes synchronously — no reactive surface. + guardModelUndefinedWrites(model, eventFormValue(new CalendarEvent())); + + const event_form = form( + model, + (p) => { + required(p.host); + required(p.date); + validate(p.duration, ({ value, valueOf }) => { + const date = valueOf(p.date); + return date && isAfter(Date.now(), addMinutes(date, value())) + ? { kind: 'duration' } + : undefined; + }); + required(p.catering_notes, { + when: ({ valueOf }) => + !!valueOf(p.catering)?.length && notes_required(), + }); + disabled(p.host, { when: () => has_id }); + disabled(p.organiser, { when: () => has_id }); + disabled(p.date, { when: () => lock_start_time() }); + disabled(p.assets, { + when: ({ valueOf }) => !valueOf(p.resources)?.length, + }); + disabled(p.duration, { + when: ({ valueOf }) => !!valueOf(p.all_day), + }); + }, + { injector }, + ) as EventForm; + + // organiser → host + onFieldChange( + model, + (v) => v.organiser, + (organiser) => + // Coalesce to '' so the `host` sub-field is never removed from the + // FieldTree (an undefined value breaks its `required`/`[formField]`). + model.update((m) => ({ + ...m, + host: (organiser as any)?.email ?? '', + })), + injector, + ); + // resources → system + onFieldChange( + model, + (v) => v.resources, + (resources) => + model.update((m) => ({ + ...m, + system: resources?.length ? resources[0] : null, + })), + injector, + ); + // Sync recurrence day-of-week when the date changes + onFieldChange( + model, + (v) => v.date, + (date) => { + const recurrence = model().recurrence; + // Non-recurring events have an empty recurrence. Writing + // days_of_week into it invents a recurrence and makes the form + // look edited when only the date was seeded. + if (!recurrence?.pattern) return; + if ( + recurrence._pattern !== 'custom_display' && + recurrence._pattern !== 'none' + ) { + model.update((m) => ({ + ...m, + recurrence: { + ...m.recurrence, + days_of_week: [new Date(date).getDay()], + }, + })); + } + }, + injector, + ); + + 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, + })), + })); + }; + // Recompute catering times when the catering list changes + onFieldChange(model, (v) => v.catering, setCateringTime, injector); + + const time_sync = setupFormTimeSync( + model, + { on_time_change: setCateringTime }, + injector, + ); + + return { model, form: event_form, time_sync, lock_start_time }; +} diff --git a/libs/events/src/lib/event-save.fn.ts b/libs/events/src/lib/event-save.fn.ts new file mode 100644 index 0000000000..b7c73606ff --- /dev/null +++ b/libs/events/src/lib/event-save.fn.ts @@ -0,0 +1,330 @@ +import type { MatDialog } from '@angular/material/dialog'; +import { + currentUser, + DEFAULT_SETTINGS, + getItemWithKeys, + getTimeInTimezone, + i18n, + isWithinBookableHours, + rulesForResource, + type BookableHoursRange, + type BookingClash, + type BookingRuleset, + type Building, + type CalendarEvent, + type OrganisationService, + type SettingsService, + type Space, + type User, +} from '@placeos/common'; +import { showMetadata } from '@placeos/ts-client'; + +import { queryResourceAvailability } from 'libs/bookings/src/lib/bookings.fn'; +import { openRecurringClashModal } from 'libs/components/src/lib/recurring-clash-modal.component'; +import type { EventFormValue } from './event-form'; +import { + findEventClashes, + querySpaceAvailability, + type CalendarEventShowParams, +} from './events.fn'; + +/** Comma separated display names of the given spaces. */ +export function spaceNames(spaces: Space[]): string { + return spaces.map((_) => _.display_name || _.name || _.email).join(', '); +} + +/** + * Throw when any of the spaces is already booked for the period. + * @param ignore Booking to ignore when events are stored as bookings + * @param event Event being edited. Its own calendar entries are ignored. + * @param book_internal Whether events are stored as bookings + */ +export async function checkSpacesAvailable( + spaces: Space[], + date: number, + duration: number, + { + ignore, + event, + book_internal, + }: { ignore?: string; event: CalendarEvent; book_internal: boolean }, +): Promise { + if (!spaces?.length) return true; + const id_list = spaces.map((_) => _.id); + const response = await (book_internal + ? queryResourceAvailability(id_list, date, duration, ignore) + : querySpaceAvailability( + id_list, + date, + duration, + event?.resources[0]?.id || + event?.system?.id || + event?.id || + undefined, + undefined, + [event?.date, event?.duration], + )); + const unavailable = spaces.filter((_, i) => !response[i]); + if (unavailable.length) { + throw i18n( + unavailable.length > 1 + ? 'CALENDAR_EVENT.SPACES_UNAVAILABLE' + : 'CALENDAR_EVENT.SPACE_UNAVAILABLE', + { spaces: spaceNames(unavailable) }, + ); + } + return true; +} + +/** + * Throw when the booking rules hide any of the spaces from the host. + * @param rules Booking rules grouped by building ID. Rules for other + * buildings of the spaces are fetched on demand. + */ +export async function checkSpaceRules( + org: OrganisationService, + spaces: Space[], + { date, duration, host }: { date: number; duration: number; host: User }, + rules: Record, +): Promise { + const building_rules = { ...rules }; + const buildings = await org.loadBuildingsForZones( + spaces.map((space) => space.zones), + ); + // The booking panel does not eagerly load zone metadata, so the + // reactive rules resource may still be empty when a booking is + // submitted. Fetch any missing building rules on demand so they are + // always enforced regardless of which app submitted the booking. + for (const space of spaces) { + const bld = buildings.find((b) => space.zones.includes(b.id)); + if (!bld || building_rules[bld.id]) continue; + const metadata = await showMetadata(bld.id, 'room_booking_rules').catch( + () => ({ details: [] }), + ); + building_rules[bld.id] = + metadata.details instanceof Array ? metadata.details : []; + } + const space_rules = spaces.map((space) => { + const bld = buildings.find((b) => space.zones.includes(b.id)); + return rulesForResource( + { date, duration, host, resource: space }, + building_rules[bld?.id], + ); + }); + const hidden = spaces.filter((_, i) => space_rules[i]?.hidden); + if (hidden.length) { + throw i18n( + 'CALENDAR_EVENT.SPACE_BOOKING_RULES_HIDDEN', + { spaces: spaceNames(hidden) }, + hidden.length, + ); + } + return true; +} + +/** Resolve an app setting against one building's override stack. */ +function buildingSetting( + org: OrganisationService, + key: string, + building: Building, +): T | undefined { + const keys = key.split('.'); + const override_keys = keys[0] === 'app' ? keys.slice(1) : keys; + const overrides = [ + org.buildingSettings(building.id), + org.regionSettings(building.parent_id), + ...(org.settings || []), + ]; + for (const override of overrides) { + const value = getItemWithKeys(override_keys, override); + if (value != null) return value as T; + } + return getItemWithKeys(keys, DEFAULT_SETTINGS) as T | undefined; +} + +/** + * Throw when the event is outside the local bookable hours of any building + * of the spaces. Without spaces, the active bookable hours apply. + */ +export async function checkBuildingBookableHours( + org: OrganisationService, + settings: SettingsService, + spaces: Space[], + date: number, + date_end: number, + organiser_timezone: string, +) { + const buildings = await org.loadBuildingsForZones( + spaces.map((space) => space.zones), + ); + await Promise.all( + buildings.map((building) => org.loadBuildingData(building)), + ); + const policies = buildings.length + ? buildings.map((building) => ({ + hours: buildingSetting( + org, + 'app.events.bookable_hours', + building, + ), + timezone: building.timezone || organiser_timezone, + })) + : [ + { + hours: settings.get( + 'app.events.bookable_hours', + ), + timezone: organiser_timezone, + }, + ]; + for (const { hours, timezone } of policies) { + if (!hours) continue; + const { hours: end_hour, minutes: end_minute } = getTimeInTimezone( + date_end, + timezone, + ); + const end_minutes = end_hour * 60 + end_minute; + const end_is_valid = + end_minutes >= hours.start * 60 && end_minutes <= hours.end * 60; + if (!isWithinBookableHours(date, hours, timezone) || !end_is_valid) { + throw i18n('FORM.BOOKABLE_HOURS_ERROR'); + } + } +} + +/** + * Check for clashing events in a recurring event series + * @param event The calendar event to check for clashes + * @returns true if no clashes or user confirmed to continue + * @throws Error if first instance clashes or clashes not allowed + */ +export async function checkRecurringClashes( + event: CalendarEvent, + settings: SettingsService, + dialog: MatDialog, +): Promise { + if (!event.recurring) { + return true; + } + + const clashes = (await findEventClashes(event, { + include_clash_time: true, + })) as BookingClash[]; + + if (!clashes?.length) { + return true; + } + + const sorted_clashes = [...clashes].sort( + (a, b) => a.booking_start - b.booking_start, + ); + + const event_start_unix = Math.floor(event.date / 1000); + const first_clash = sorted_clashes[0]; + const is_first_instance_clash = + first_clash.booking_start === event_start_unix; + + if (is_first_instance_clash) { + throw i18n('CALENDAR_EVENT.FIRST_INSTANCE_CLASH'); + } + + const allow_clashes = + settings.get('app.events.allow_recurring_instance_clashes') ?? false; + + if (!allow_clashes) { + throw i18n('CALENDAR_EVENT.RECURRING_CLASHES_NOT_ALLOWED', { + count: clashes.length, + }); + } + + const result = await openRecurringClashModal( + { clashes: sorted_clashes }, + dialog, + ); + + if (result?.reason !== 'done') { + throw 'User cancelled'; + } + + return true; +} + +/** + * Build the query used to save an event. The calendar is only set when the + * current user owns the event, or when the caller forces it. + * @param event Event before the edit. Empty for a new event. + * @param value Form value being saved + * @param spaces Spaces booked for the event + */ +export function eventSaveQuery( + event: CalendarEvent, + value: EventFormValue, + spaces: Space[], + options: { + notify_new_attendees_only: boolean; + ignore_owner: boolean; + force_calendar: boolean; + }, +): CalendarEventShowParams { + const query: CalendarEventShowParams = event.id + ? { + system_id: + event?.resources[0]?.id || event?.system?.id || spaces[0]?.id, + } + : {}; + if (options.notify_new_attendees_only) + query.notify_existing_attendees = false; + const user_email = currentUser()?.email?.toLowerCase() || ''; + const source_calendar = + event.calendar || + event.host || + event.creator || + value.calendar || + value.creator; + const target_calendar = value.host || value.creator; + const query_calendar = event.id ? source_calendar : target_calendar; + const owner_fields = event.id + ? [event.host, event.creator, event.calendar] + : [value.host, value.creator, value.calendar]; + const is_owner = owner_fields.some( + (_) => _?.toLowerCase?.() === user_email, + ); + if ( + ((is_owner && !options.ignore_owner) || options.force_calendar) && + query_calendar + ) + query.calendar = query_calendar; + return query; +} + +/** Status and message fields that API errors can hold. */ +interface ErrorDetails { + status?: number; + message?: unknown; +} + +/** Shape of an error from the API client, possibly nested in `error`. */ +type ApiError = ErrorDetails & { error?: string | ErrorDetails }; + +/** Readable message of a thrown error, or an empty string. */ +export function errorMessage(error: unknown): string { + if (typeof error === 'string') return error; + if (error instanceof Error && error.message) return error.message; + const api_error = error as ApiError | null | undefined; + const nested = api_error?.error; + if (typeof nested === 'string') return nested; + if (typeof api_error?.message === 'string') return api_error.message; + if (typeof nested?.message === 'string') return nested.message; + return ''; +} + +/** Whether a thrown error means that the user may not save the event. */ +export function isPermissionError(error: unknown): boolean { + const api_error = error as ApiError | null | undefined; + const nested = api_error?.error; + const status = + api_error?.status || (typeof nested === 'object' && nested?.status); + if (status === 403) return true; + const message = errorMessage(error).toLowerCase(); + return /forbidden|permission|authori[sz]ed|not permitted/.test(message); +} diff --git a/libs/events/src/lib/events.ts b/libs/events/src/lib/events.ts index d0e8407d7c..de34d08a81 100644 --- a/libs/events/src/lib/events.ts +++ b/libs/events/src/lib/events.ts @@ -1,3 +1,4 @@ +export * from './event-form'; export * from './event.interfaces'; export * from './events.fn'; export * from './helpers'; diff --git a/libs/events/src/lib/utilities.ts b/libs/events/src/lib/utilities.ts index a9e4c1b560..5eff2b5160 100644 --- a/libs/events/src/lib/utilities.ts +++ b/libs/events/src/lib/utilities.ts @@ -1,31 +1,11 @@ -import { - computed, - Injector, - signal, - type Signal, - type WritableSignal, -} from '@angular/core'; -import { - disabled, - form, - required, - validate, - type FieldTree, -} from '@angular/forms/signals'; +import { computed, type Signal } from '@angular/core'; import { Booking, CalendarEvent, - currentUser, - guardModelUndefinedWrites, - LOCAL_TIMEZONE, - onFieldChange, SettingsService, - setupFormTimeSync, timePeriodsIntersect, unique, User, - type FormTimeSyncHandle, - type SignalFormRef, } from '@placeos/common'; import { add, @@ -34,11 +14,9 @@ import { format, formatDuration, getTime, - isAfter, isSameDay, setHours, setMinutes, - startOfDay, startOfMinute, } from 'date-fns'; @@ -75,259 +53,6 @@ export function organiserTimezone(user?: User): string { return typeof timezone === 'string' ? timezone : ''; } -/** Raw value held by the event form model. */ -export interface EventFormValue { - id: string; - ical_uid: string; - host: string; - organiser: User; - creator: string; - calendar: string; - attendees: any[]; - resources: any[]; - title: string; - body: string; - private: boolean; - date: number; - duration: number; - all_day: boolean; - date_end: number; - recurring: boolean; - recurrence: any; - recurring_event_id: string; - master: any; - attachments: any; - catering: any[]; - catering_notes: string; - catering_charge_code: string; - setup_time: number; - breakdown_time: number; - assets: any[]; - visitor_type: any; - location: string; - visibility: string; - needs_space: boolean; - needs_parking: boolean; - event_type: string; - category: string; - tags: string[]; - update_master: boolean; - system: any; - attendance_type: string; - timezone: string; - shared_event: boolean; - view_access: string; - images: any[]; - featured: boolean; - meeting_provider: any; -} - -/** Build the raw event form value from a calendar event. */ -export function eventFormValue( - event: CalendarEvent = new CalendarEvent(), -): EventFormValue { - // Every field must be non-`undefined`: the signal-forms FieldTree only - // exposes a sub-field for keys whose value is defined, so an `undefined` - // seed would make `[formField]="form.x"` bind to nothing at runtime. - return { - id: event.id || '', - ical_uid: event.ical_uid || '', - host: - event.host || event.organiser?.email || currentUser()?.email || '', - organiser: event.organiser || ({ email: event.host || '' } as User), - creator: event.creator || currentUser()?.email || '', - calendar: event.calendar || '', - attendees: event.attendees || [], - resources: event.resources || [], - title: event.title || '', - body: event.body || '', - private: event.private ?? false, - date: event.date ?? 0, - duration: event.duration ?? 0, - all_day: event.all_day ?? false, - date_end: event.date_end ?? 0, - recurring: event.recurring ?? false, - recurrence: event.recurrence ?? null, - recurring_event_id: event.recurring_event_id || '', - master: event.master ?? null, - attachments: event.attachments ?? null, - catering: (event.extension_data?.catering as any) || [], - catering_notes: event.extension_data?.catering?.[0]?.notes || '', - catering_charge_code: - event.extension_data?.catering?.[0]?.charge_code || '', - setup_time: event.setup_time || 0, - breakdown_time: event.breakdown_time || 0, - assets: event.extension_data?.assets || [], - visitor_type: event.extension_data?.visitor_type ?? null, - location: event.location || '', - visibility: event.visibility || 'normal', - needs_space: true, - needs_parking: event.extension_data?.needs_parking || false, - event_type: event.extension_data?.event_type || '', - category: event.extension_data?.category || '', - tags: event.extension_data?.tags || [], - update_master: false, - system: event.system ?? null, - attendance_type: event.extension_data?.attendance_type || 'ONSITE', - timezone: event.timezone || LOCAL_TIMEZONE, - shared_event: event.extension_data?.shared_event || false, - view_access: event.extension_data?.view_access || 'OPEN', - images: event.extension_data?.images || [], - featured: event.extension_data?.featured || false, - meeting_provider: event.meeting_provider || null, - } as EventFormValue; -} - -/** FieldTree for the event form, with attached time-sync + lock handles. */ -export type EventForm = FieldTree & { - _time_sync?: FormTimeSyncHandle; - _lock_start_time?: WritableSignal; -}; - -export type EventFormRef = SignalFormRef & { - /** Time-sync handle for runtime duration/bookable-hours reconfiguration. */ - time_sync: FormTimeSyncHandle; - /** Signal controlling whether the start time is locked (in-progress events). */ - lock_start_time: WritableSignal; -}; - -export function generateEventForm( - event: CalendarEvent = new CalendarEvent(), - settings?: SettingsService, - injector?: Injector, -): EventFormRef { - if (!event) event = new CalendarEvent(); - const lock_start_time = signal( - !!event.id && - (event.state === 'started' || event.state === 'in_progress'), - ); - const has_id = !!event.id; - const notes_required = () => - !!( - settings?.get('app.events.catering_notes_required') || - settings?.value('require_catering_notes') - ); - - const model: WritableSignal = signal(eventFormValue(event)); - - // Keep every key defined so signal-forms never drops a sub-field bound via - // `[formField]` (an undefined value triggers `this.field() is not a - // function`). Guards writes synchronously — no reactive surface. - guardModelUndefinedWrites(model, eventFormValue(new CalendarEvent())); - - const event_form = form( - model, - (p) => { - required(p.host); - required(p.date); - validate(p.duration, ({ value, valueOf }) => { - const date = valueOf(p.date); - return date && isAfter(Date.now(), addMinutes(date, value())) - ? { kind: 'duration' } - : undefined; - }); - required(p.catering_notes, { - when: ({ valueOf }) => - !!valueOf(p.catering)?.length && notes_required(), - }); - disabled(p.host, { when: () => has_id }); - disabled(p.organiser, { when: () => has_id }); - disabled(p.date, { when: () => lock_start_time() }); - disabled(p.assets, { - when: ({ valueOf }) => !valueOf(p.resources)?.length, - }); - disabled(p.duration, { - when: ({ valueOf }) => !!valueOf(p.all_day), - }); - }, - { injector }, - ) as EventForm; - - // organiser → host - onFieldChange( - model, - (v) => v.organiser, - (organiser) => - // Coalesce to '' so the `host` sub-field is never removed from the - // FieldTree (an undefined value breaks its `required`/`[formField]`). - model.update((m) => ({ - ...m, - host: (organiser as any)?.email ?? '', - })), - injector, - ); - // resources → system - onFieldChange( - model, - (v) => v.resources, - (resources) => - model.update((m) => ({ - ...m, - system: resources?.length ? resources[0] : null, - })), - injector, - ); - // Sync recurrence day-of-week when the date changes - onFieldChange( - model, - (v) => v.date, - (date) => { - const recurrence = model().recurrence; - // Non-recurring events have an empty recurrence. Writing - // days_of_week into it invents a recurrence and makes the form - // look edited when only the date was seeded. - if (!recurrence?.pattern) return; - if ( - recurrence._pattern !== 'custom_display' && - recurrence._pattern !== 'none' - ) { - model.update((m) => ({ - ...m, - recurrence: { - ...m.recurrence, - days_of_week: [new Date(date).getDay()], - }, - })); - } - }, - injector, - ); - - 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, - })), - })); - }; - // Recompute catering times when the catering list changes - onFieldChange(model, (v) => v.catering, setCateringTime, injector); - - const time_sync = setupFormTimeSync( - model, - { on_time_change: setCateringTime }, - injector, - ); - - return { model, form: event_form, time_sync, lock_start_time }; -} - /** * Set the initial time used for generating mock bookings * @param time New initial time as ms from UTC epoch diff --git a/libs/events/src/tests/utilities.spec.ts b/libs/events/src/tests/utilities.spec.ts index f76d73f921..5fbf2f00e0 100644 --- a/libs/events/src/tests/utilities.spec.ts +++ b/libs/events/src/tests/utilities.spec.ts @@ -2,8 +2,8 @@ import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CalendarEvent } from '@placeos/common'; +import { generateEventForm } from '../lib/event-form'; import { generateSystemsFormFields } from '../lib/space.utilities'; -import { generateEventForm } from '../lib/utilities'; describe('utilities', () => { let injector: Injector; diff --git a/libs/explore/src/lib/explore-booking-rules.ts b/libs/explore/src/lib/explore-booking-rules.ts new file mode 100644 index 0000000000..d2c5df32ef --- /dev/null +++ b/libs/explore/src/lib/explore-booking-rules.ts @@ -0,0 +1,19 @@ +import { BookingRuleset } from '@placeos/common'; +import { showMetadata } from '@placeos/ts-client'; + +/** + * Load the booking rules for one type of resource in a building. + * Gives an empty list when the building has no rules or the request fails. + * @param building_id ID of the building zone + * @param type Prefix of the metadata key, e.g. `desk` for `desk_booking_rules` + */ +export function loadBookingRules( + building_id: string, + type: 'desk' | 'parking' | 'room', +): Promise { + return showMetadata(building_id, `${type}_booking_rules`) + .then((_) => + _?.details instanceof Array ? (_.details as BookingRuleset[]) : [], + ) + .catch(() => [] as BookingRuleset[]); +} diff --git a/libs/explore/src/lib/explore-desks.service.ts b/libs/explore/src/lib/explore-desks.service.ts index 3e10b437b6..69a3edc9cb 100644 --- a/libs/explore/src/lib/explore-desks.service.ts +++ b/libs/explore/src/lib/explore-desks.service.ts @@ -38,6 +38,7 @@ import { BookingFormService } from 'libs/bookings/src/lib/booking-form.service'; import { queryBookings } from 'libs/bookings/src/lib/bookings.fn'; import { SetDatetimeModalComponent } from 'libs/explore/src/lib/set-datetime-modal.component'; +import { loadBookingRules } from './explore-booking-rules'; import { ExploreDeskInfoComponent } from './explore-desk-info.component'; import { ExploreDeviceInfoComponent } from './explore-device-info.component'; import { DEFAULT_COLOURS } from './explore-spaces.service'; @@ -87,14 +88,7 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy { private _booking_rules = resource({ params: () => this._building() || undefined, - loader: ({ params: bld }) => - showMetadata(bld.id, `desk_booking_rules`) - .then((_) => - _?.details instanceof Array - ? (_.details as any as BookingRuleset[]) - : [], - ) - .catch(() => [] as BookingRuleset[]), + loader: ({ params: bld }) => loadBookingRules(bld.id, 'desk'), }); public readonly booking_rules = computed( () => this._booking_rules.value() ?? [], diff --git a/libs/explore/src/lib/explore-parking.service.ts b/libs/explore/src/lib/explore-parking.service.ts index 001cf8d9e4..fb0b43734d 100644 --- a/libs/explore/src/lib/explore-parking.service.ts +++ b/libs/explore/src/lib/explore-parking.service.ts @@ -22,8 +22,11 @@ import { SettingsService, StaffUser, user_group_names, + ViewAction, + ViewerFeature, + ViewerStyles, } from '@placeos/common'; -import { PlaceAsset, showMetadata } from '@placeos/ts-client'; +import { PlaceAsset } from '@placeos/ts-client'; import { addMinutes, endOfDay, @@ -42,6 +45,7 @@ import { queryBookings, } from 'libs/bookings/src/lib/bookings.fn'; import { ParkingService } from 'libs/bookings/src/lib/parking.service'; +import { loadBookingRules } from './explore-booking-rules'; import { ExploreParkingInfoComponent } from './explore-parking-info.component'; import { DEFAULT_COLOURS } from './explore-spaces.service'; import { ExploreStateService } from './explore-state.service'; @@ -60,6 +64,19 @@ export interface ParkingOptions { user?: string; } +type ParkingStatus = 'not-bookable' | 'busy' | 'pending' | 'free'; + +/** Map status of a parking space. Sets its colour and the text on its card. */ +function parkingStatus( + space: ParkingSpace, + is_assigned: boolean, + can_book: boolean, +): ParkingStatus { + if (space.bookable === false) return 'not-bookable'; + if (!can_book) return 'busy'; + return is_assigned ? 'pending' : 'free'; +} + @Injectable() export class ExploreParkingService extends AsyncHandler { private _org = inject(OrganisationService); @@ -88,14 +105,7 @@ export class ExploreParkingService extends AsyncHandler { private _booking_rules = resource({ params: () => this._building() || undefined, - loader: ({ params: bld }) => - showMetadata(bld.id, `parking_booking_rules`) - .then((_) => - _?.details instanceof Array - ? (_.details as any as BookingRuleset[]) - : [], - ) - .catch(() => [] as BookingRuleset[]), + loader: ({ params: bld }) => loadBookingRules(bld.id, 'parking'), }); public readonly booking_rules = computed( () => this._booking_rules.value() ?? [], @@ -114,37 +124,26 @@ export class ExploreParkingService extends AsyncHandler { loader: ({ params: { is_public, level_id, date, all_day, duration }, }) => { + if (is_public || !level_id) return Promise.resolve([]); const time = date ?? Date.now(); - const bookable_hours: BookableHoursRange | null = all_day - ? this._settings.get('app.parking.bookable_hours') || - this._settings.get('app.bookings.bookable_hours') || - null - : null; - const all_day_range = getAllDayTimeRange( - time, - '', - bookable_hours?.start, - bookable_hours?.end, - ); - return is_public || !level_id - ? Promise.resolve([]) - : queryAllBookings({ - period_start: getUnixTime( - all_day - ? all_day_range.date - : duration - ? time - : addMinutes(time, -15), - ), - period_end: getUnixTime( - all_day - ? all_day_range.date_end - : addMinutes(time, duration || 30), - ), - type: 'parking', - zones: level_id, - rejected: false, - }).catch(() => []); + const hours = this._bookableHours(); + const day = getAllDayTimeRange(time, '', hours?.start, hours?.end); + // Without all day or a duration, look 15 minutes back and 30 forward + const start = all_day + ? day.date + : duration + ? time + : addMinutes(time, -15); + const end = all_day + ? day.date_end + : addMinutes(time, duration || 30); + return queryAllBookings({ + period_start: getUnixTime(start), + period_end: getUnixTime(end), + type: 'parking', + zones: level_id, + rejected: false, + }).catch(() => []); }, }); /** List of current bookings for the current map level */ @@ -259,15 +258,25 @@ export class ExploreParkingService extends AsyncHandler { this._options.update((value) => ({ ...value, ...options })); } + /** Bookable hours for parking, or for all bookings when parking has none */ + private _bookableHours(): BookableHoursRange | null { + return ( + this._settings.get('app.parking.bookable_hours') || + this._settings.get('app.bookings.bookable_hours') || + null + ); + } + + /** Set the map styles, info cards and booking actions of the spaces */ private _updateParkingSpaces( spaces: ParkingSpace[], available: ParkingSpace[], ) { - const styles = {}; - const features = []; - const actions = []; + const styles: ViewerStyles = {}; + const features: ViewerFeature[] = []; + const actions: ViewAction[] = []; const colours = this._settings.get('app.explore.colors') || {}; - let options = this._options(); + const options = this._options(); for (const space of spaces) { const can_book = !!available.find((_) => _.id === space.id); const is_workplace = @@ -275,16 +284,7 @@ export class ExploreParkingService extends AsyncHandler { this._settings.app_name.toLowerCase().includes('staff'); const is_assigned = is_workplace ? false : !!space.assigned_to; const id = space.map_id || space.id; - const status = - space.bookable === false - ? 'not-bookable' - : is_assigned - ? can_book - ? 'pending' - : 'busy' - : can_book - ? 'free' - : 'busy'; + const status = parkingStatus(space, is_assigned, can_book); styles[`#${id}`] = { fill: colours[`parking-${status}`] || @@ -308,118 +308,11 @@ export class ExploreParkingService extends AsyncHandler { }, }); if (!can_book) continue; - const book_fn = async () => { - if (this.on_book) { - await this.on_book(space); - this._poll.set(Date.now()); - return; - } - if (this._deny_parking_access()) { - const space_zone = this._org.levelWithID([space.zone_id]); - return notifyError( - i18n('EXPLORE.PARKING_PERMISSIONS_ERROR', { - name: space_zone?.display_name || space_zone?.name, - }), - ); - } - if (this._assigned_space() && this._booked_space()) { - return notifyError( - i18n('EXPLORE.PARKING_ASSIGNED_ERROR', { - name: space.name || space.id, - }), - ); - } - if (this._booked_space()) { - return notifyError(i18n('EXPLORE.PARKING_EXISTING_ERROR')); - } - if (status !== 'free') { - return notifyError( - i18n('EXPLORE.PARKING_AVAILABLE_ERROR', { - name: space.name || 'Parking Space', - }), - ); - } - if ( - space.place_groups?.length && - !space.place_groups.find((_) => - currentUser().groups.includes(_), - ) - ) { - return notifyError( - i18n('EXPLORE.PARKING_GROUP_ERROR', { - name: space.name, - }), - ); - } - this._bookings.newForm('parking'); - this._bookings.setOptions({ type: 'parking' }); - options = this._options(); - const bookable_hours: BookableHoursRange | null = - this._settings.get('app.parking.bookable_hours') || - this._settings.get('app.bookings.bookable_hours') || - null; - if ( - bookable_hours && - !this._settings.get('app.parking.allow_time_changes') && - !isWithinBookableHours(Date.now(), bookable_hours) - ) { - return notifyError(i18n('EXPLORE.OUTSIDE_BOOKABLE_HOURS')); - } - let user = options.host || currentUser(); - const user_email = user?.email; - const zone = - this._org.levelWithID([ - space.zone_id || (space as any).zone, - ]) || this._state.active_level; - let date = - !options.date || isSameDay(options.date, Date.now()) - ? startOfMinute(Date.now()).valueOf() - : setHours(options.date, 8).valueOf(); - if (bookable_hours) { - date = alignDateToBookableHours(date, bookable_hours); - } - this._bookings.model.update((m) => ({ - ...m, - resources: [space], - asset_id: space.id, - asset_name: space.name, - date, - duration: 11 * 60, - all_day: true, - map_id: space?.map_id || space?.id, - description: space.name, - user, - user_email, - booking_type: 'parking', - zones: [ - this._org.organisation.id, - this._org.region?.id, - zone?.parent_id, - zone?.id, - ], - })); - await this._bookings.confirmPost().catch((e) => { - if (e === 'User cancelled') throw e; - notifyError( - i18n('EXPLORE.PARKING_BOOKING_ERROR', { - name: space.name || space.id, - error: e.message || e.error || e, - }), - ); - throw e; - }); - notifySuccess( - i18n('EXPLORE.PARKING_BOOKING_SUCCESS', { - name: space.name || space.id, - }), - ); - this.timeout('poll', () => this._poll.set(Date.now()), 1000); - }; actions.push({ id, action: 'click', priority: 10, - callback: book_fn, + callback: () => this._bookSpace(space, status), }); } this._state.setActions( @@ -429,4 +322,110 @@ export class ExploreParkingService extends AsyncHandler { this._state.setStyles('parking', styles); this._state.setFeatures('parking', features); } + + /** + * Book a space that the user clicked on the map. + * Uses `on_book` when it is set. Otherwise checks that the user can book + * the space, then opens the booking form for it. + */ + private async _bookSpace(space: ParkingSpace, status: ParkingStatus) { + if (this.on_book) { + await this.on_book(space); + this._poll.set(Date.now()); + return; + } + const error = this._bookingError(space, status); + if (error) return notifyError(error); + this._bookings.newForm('parking'); + this._bookings.setOptions({ type: 'parking' }); + const options = this._options(); + const bookable_hours = this._bookableHours(); + if ( + bookable_hours && + !this._settings.get('app.parking.allow_time_changes') && + !isWithinBookableHours(Date.now(), bookable_hours) + ) { + return notifyError(i18n('EXPLORE.OUTSIDE_BOOKABLE_HOURS')); + } + const user = options.host || currentUser(); + const user_email = user?.email; + const zone = + this._org.levelWithID([ + space.zone_id || + (space as ParkingSpace & { zone?: string }).zone, + ]) || this._state.active_level; + let date = + !options.date || isSameDay(options.date, Date.now()) + ? startOfMinute(Date.now()).valueOf() + : setHours(options.date, 8).valueOf(); + if (bookable_hours) { + date = alignDateToBookableHours(date, bookable_hours); + } + this._bookings.model.update((m) => ({ + ...m, + resources: [space], + asset_id: space.id, + asset_name: space.name, + date, + duration: 11 * 60, + all_day: true, + map_id: space?.map_id || space?.id, + description: space.name, + user, + user_email, + booking_type: 'parking', + zones: [ + this._org.organisation.id, + this._org.region?.id, + zone?.parent_id, + zone?.id, + ], + })); + await this._bookings.confirmPost().catch((e) => { + if (e === 'User cancelled') throw e; + notifyError( + i18n('EXPLORE.PARKING_BOOKING_ERROR', { + name: space.name || space.id, + error: e.message || e.error || e, + }), + ); + throw e; + }); + notifySuccess( + i18n('EXPLORE.PARKING_BOOKING_SUCCESS', { + name: space.name || space.id, + }), + ); + this.timeout('poll', () => this._poll.set(Date.now()), 1000); + } + + /** Message that tells why the user cannot book the space, or null */ + private _bookingError(space: ParkingSpace, status: ParkingStatus) { + if (this._deny_parking_access()) { + const space_zone = this._org.levelWithID([space.zone_id]); + return i18n('EXPLORE.PARKING_PERMISSIONS_ERROR', { + name: space_zone?.display_name || space_zone?.name, + }); + } + if (this._assigned_space() && this._booked_space()) { + return i18n('EXPLORE.PARKING_ASSIGNED_ERROR', { + name: space.name || space.id, + }); + } + if (this._booked_space()) { + return i18n('EXPLORE.PARKING_EXISTING_ERROR'); + } + if (status !== 'free') { + return i18n('EXPLORE.PARKING_AVAILABLE_ERROR', { + name: space.name || 'Parking Space', + }); + } + if ( + space.place_groups?.length && + !space.place_groups.find((_) => currentUser().groups.includes(_)) + ) { + return i18n('EXPLORE.PARKING_GROUP_ERROR', { name: space.name }); + } + return null; + } } diff --git a/libs/explore/src/lib/explore-spaces.service.ts b/libs/explore/src/lib/explore-spaces.service.ts index f2b4396aec..f2ebfc3ff4 100644 --- a/libs/explore/src/lib/explore-spaces.service.ts +++ b/libs/explore/src/lib/explore-spaces.service.ts @@ -29,6 +29,7 @@ import { EventFormService } from 'libs/events/src/lib/event-form.service'; import { ExploreBookQrComponent } from './explore-book-qr.component'; import { ExploreBookingModalComponent } from './explore-booking-modal.component'; +import { loadBookingRules } from './explore-booking-rules'; import { ExploreIconComponent } from './explore-icon.component'; import { ExploreSpaceInfoComponent } from './explore-space-info.component'; import { ExploreStateService } from './explore-state.service'; @@ -61,14 +62,7 @@ export class ExploreSpacesService extends AsyncHandler implements OnDestroy { private _booking_rules = resource({ params: () => this._building() || undefined, - loader: ({ params: bld }) => - showMetadata(bld.id, `room_booking_rules`) - .then((_) => - _?.details instanceof Array - ? (_.details as any as BookingRuleset[]) - : [], - ) - .catch(() => [] as BookingRuleset[]), + loader: ({ params: bld }) => loadBookingRules(bld.id, 'room'), }); public readonly booking_rules = computed( () => this._booking_rules.value() ?? [], diff --git a/libs/explore/src/lib/set-datetime-modal.component.ts b/libs/explore/src/lib/set-datetime-modal.component.ts index 008f7ad0bc..2009b4c8c2 100644 --- a/libs/explore/src/lib/set-datetime-modal.component.ts +++ b/libs/explore/src/lib/set-datetime-modal.component.ts @@ -17,7 +17,7 @@ import { User, } from '@placeos/common'; -import { BookingAsset } from 'libs/bookings/src/lib/booking-form.service'; +import { BookingAsset } from 'libs/bookings/src/lib/booking-form.types'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { DateFieldComponent } from 'libs/form-fields/src/lib/date-field.component'; diff --git a/libs/explore/src/tests/explore-desks.service.spec.ts b/libs/explore/src/tests/explore-desks.service.spec.ts index d4a9a35791..163e13791e 100644 --- a/libs/explore/src/tests/explore-desks.service.spec.ts +++ b/libs/explore/src/tests/explore-desks.service.spec.ts @@ -8,8 +8,8 @@ import { SettingsService, } from '@placeos/common'; +import { generateBookingForm } from 'libs/bookings/src/lib/booking-form.model'; import { BookingFormService } from 'libs/bookings/src/lib/booking-form.service'; -import { generateBookingForm } from 'libs/bookings/src/lib/booking.utilities'; import { DesksService } from 'libs/bookings/src/lib/desk.service'; import { ExploreDesksService } from '../lib/explore-desks.service'; diff --git a/libs/explore/src/tests/set-datetime-modal.component.spec.ts b/libs/explore/src/tests/set-datetime-modal.component.spec.ts index 2353d009e5..bd37fa588c 100644 --- a/libs/explore/src/tests/set-datetime-modal.component.spec.ts +++ b/libs/explore/src/tests/set-datetime-modal.component.spec.ts @@ -1,7 +1,7 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; import { SettingsService } from '@placeos/common'; -import { BookingAsset } from 'libs/bookings/src/lib/booking-form.service'; +import { BookingAsset } from 'libs/bookings/src/lib/booking-form.types'; import { MockPipe, MockProvider, ngMocks } from 'ng-mocks'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe';