diff --git a/apps/concierge/src/app/parking/parking-special-request-modal.component.ts b/apps/concierge/src/app/parking/parking-special-request-modal.component.ts index 1664d30152..afafbedc01 100644 --- a/apps/concierge/src/app/parking/parking-special-request-modal.component.ts +++ b/apps/concierge/src/app/parking/parking-special-request-modal.component.ts @@ -75,11 +75,9 @@ export class ParkingSpecialRequestModalComponent { const urls: string[] = ( this._data.booking?.extension_data?.attachments || [] ).filter((url) => !!url); - const names: string[] = - this._data.booking?.extension_data?.p2_document_names || []; - return urls.map((url, index) => ({ + return urls.map((url) => ({ url, - name: names[index] || this._fileNameFromUrl(url), + name: this._fileNameFromUrl(url), })); })(); diff --git a/apps/concierge/src/app/parking/parking.component.ts b/apps/concierge/src/app/parking/parking.component.ts index fe959b6eb4..2e7e69f2a2 100644 --- a/apps/concierge/src/app/parking/parking.component.ts +++ b/apps/concierge/src/app/parking/parking.component.ts @@ -38,7 +38,7 @@ import { ParkingTopbarComponent } from './parking-topbar.component'; | translate }} - @if (!hide_users_and_vehicles) { + @if (!hide_users) {
-

- {{ - 'APP.CONCIERGE.REPORTS_PARKING_UTIL_HEADER' | translate - }} -

+

Parking Bookings

@if (!print()) {
({ ...m, user })); + this._booking_form.model.update((m) => ({ + ...m, + user: user || EMPTY_USER, + })); } public setDuration(duration: number) { @@ -324,6 +328,8 @@ export class VisitorRegistrationComponent ...m, booking_type: 'visitor', title: 'Visit', + // Always ask for the host; null is sanitized back to currentUser(). + user: EMPTY_USER, })); setTimeout(() => { if (this.allow_self_registration()) return; @@ -343,6 +349,13 @@ export class VisitorRegistrationComponent }), ); } + if (!this.host()) { + return notifyError( + i18n('FORM.INVALID_FIELDS', { + field_list: i18n('APP.VISITOR_KIOSK.HOST'), + }), + ); + } this.loading.set(true); try { const value = this._booking_form.model(); diff --git a/apps/visitor-kiosk/src/tests/checkin/checkin-covid.component.spec.ts b/apps/visitor-kiosk/src/tests/checkin/checkin-covid.component.spec.ts index 512839b7f6..e428df2513 100644 --- a/apps/visitor-kiosk/src/tests/checkin/checkin-covid.component.spec.ts +++ b/apps/visitor-kiosk/src/tests/checkin/checkin-covid.component.spec.ts @@ -5,7 +5,10 @@ import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest'; import { CheckinCovidComponent } from '../../app/checkin/checkin-covid.component'; import { CheckinStateService } from '../../app/checkin/checkin-state.service'; -jest.mock('@placeos/common'); +jest.mock('@placeos/common', () => ({ + ...jest.requireActual('@placeos/common'), + notifyError: jest.fn(), +})); import { FormsModule } from '@angular/forms'; import * as common_mod from '@placeos/common'; @@ -26,14 +29,16 @@ describe('CheckinCovidComponent', () => { imports: [MatRadioModule, FormsModule], }); - beforeEach(() => (spectator = createComponent())); + beforeEach(() => { + (common_mod.notifyError as jest.Mock).mockClear(); + spectator = createComponent(); + }); it('should create component', () => { expect(spectator.component).toBeTruthy(); }); it('should allow confirming questions', () => { - (common_mod.notifyError as any) = jest.fn(); spectator.component.confirm(); expect(common_mod.notifyError).toHaveBeenCalledWith( 'Please select yes or no for each question', diff --git a/apps/visitor-kiosk/src/tests/visitor-registration.component.spec.ts b/apps/visitor-kiosk/src/tests/visitor-registration.component.spec.ts index 24c5ca296e..1c55064680 100644 --- a/apps/visitor-kiosk/src/tests/visitor-registration.component.spec.ts +++ b/apps/visitor-kiosk/src/tests/visitor-registration.component.spec.ts @@ -4,6 +4,8 @@ import { Router } from '@angular/router'; import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest'; import { BookingFormService } from '@placeos/bookings'; import { + EMPTY_USER, + isEmptyUser, OrganisationService, Region, SettingsService, @@ -85,6 +87,14 @@ describe('VisitorRegistrationComponent', () => { expect(spectator.component.host()).toEqual(host); }); + it('should keep cleared host blank in the backing model', () => { + spectator.component.setHost(null); + + expect(spectator.component.form_value().user).toEqual(EMPTY_USER); + expect(isEmptyUser(spectator.component.form_value().user)).toBe(true); + expect(spectator.component.host()).toBeNull(); + }); + it('should keep the host field bindable when the user value is cleared', fakeAsync(() => { const console_error = jest .spyOn(console, 'error') diff --git a/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts b/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts index 0798c3d0af..5782c6b163 100644 --- a/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts +++ b/apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts @@ -96,11 +96,17 @@ export class NewDeskFlowFormComponent implements OnInit { public level = ''; public levels = []; - /** Block the form when the user has a reserved desk and isn't allowed to book another */ + /** + * Blanket-block the form only when the user has a reserved desk and booking + * with a reserved desk is disallowed entirely. When it is allowed, a + * `prevent_self_booking_if_assigned_resource` restriction blocks only their + * own bookings (enforced at submit), so the form stays open for booking on + * behalf of others. + */ public readonly show_reserved_desk_overlay = computed( () => this._state.has_assigned_desk() && - !this._state.canBookWithReservedDesk(), + !this._state.allowsBookingWithReservedResource('desk'), ); public get form() { diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts b/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts index 615c98a0bb..64e286264a 100644 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts +++ b/apps/workplace/src/app/book/parking-request-flow/parking-request-form-details.component.ts @@ -1721,10 +1721,9 @@ export class ParkingRequestFormDetailsComponent const model = this.model; if (!form || !model) return; this.supporting_doc_names.set( - model().p2_document_names || - (model().attachments || []).map((url) => - this._fileNameFromUrl(url), - ), + (model().attachments || []).map((url) => + this._fileNameFromUrl(url), + ), ); runInInjectionContext(this._injector, () => effect((onCleanup) => { @@ -2129,7 +2128,7 @@ export class ParkingRequestFormDetailsComponent } const model = this.model; const existing_urls: string[] = model?.().attachments || []; - const existing_names: string[] = model?.().p2_document_names || []; + const existing_names = this.supporting_doc_names(); const new_urls: string[] = []; const uploaded_names: string[] = []; for (const file of valid_files) { @@ -2147,7 +2146,6 @@ export class ParkingRequestFormDetailsComponent this.supporting_doc_names.set(names); model?.update((m) => ({ ...m, - p2_document_names: names, attachments: urls, })); input.value = ''; @@ -2155,7 +2153,7 @@ export class ParkingRequestFormDetailsComponent public removeSupportingDoc(index: number) { const model = this.model; - const names = [...(model?.().p2_document_names || [])]; + const names = [...this.supporting_doc_names()]; const urls = [...(model?.().attachments || [])]; if (index < 0 || index >= names.length) return; names.splice(index, 1); @@ -2163,7 +2161,6 @@ export class ParkingRequestFormDetailsComponent this.supporting_doc_names.set(names); model?.update((m) => ({ ...m, - p2_document_names: names, attachments: urls, })); } diff --git a/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts b/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts index 63dc9809f9..35a631365c 100644 --- a/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts +++ b/apps/workplace/src/app/book/parking-request-flow/parking-request-form.component.ts @@ -275,7 +275,6 @@ export class ParkingRequestFormComponent space_restrictions: false, extra_space_restrictions: [], prefer_booked_location_first: false, - p2_document_names: [], attachments: [], recurrence_type: 'none', }; diff --git a/apps/workplace/src/app/schedule/schedule-day-view.component.ts b/apps/workplace/src/app/schedule/schedule-day-view.component.ts index b86bbe3a77..d9e2629c11 100644 --- a/apps/workplace/src/app/schedule/schedule-day-view.component.ts +++ b/apps/workplace/src/app/schedule/schedule-day-view.component.ts @@ -38,7 +38,11 @@ import { setMinutes, startOfDay, } from 'date-fns'; -import { ScheduleStateService } from './schedule-state.service'; +import { + bookedForLabel, + isBookingForOtherUser, + ScheduleStateService, +} from './schedule-state.service'; interface TimeSlot { hour: number; @@ -137,7 +141,7 @@ interface PositionedBooking { colors[type(item.booking)][1] " [style.background-color]=" - colors[type(item.booking)][0] + backgroundColor(item.booking) " [style.z-index]="10" (click)="viewBooking(item.booking)" @@ -151,6 +155,10 @@ interface PositionedBooking { ? ' ' + visitorName(item.booking) : '') + + (isBookingForOtherUser(item.booking) + ? ' +for ' + bookedForLabel(item.booking) + : '') + ' ' + ($any(item.booking).user_name || @@ -270,9 +278,7 @@ interface PositionedBooking { } @if ( item.height > 7 && - !$any(item.booking).host && - $any(item.booking).user_email !== - $any(item.booking).booked_by_email + isBookingForOtherUser(item.booking) ) {
+
+ for + {{ bookedForLabel(item.booking) }} +
} } @@ -572,6 +584,14 @@ export class ScheduleDayViewComponent extends AsyncHandler implements OnInit { return bookingLocationString(booking, this._org); } + public backgroundColor(booking: Booking | CalendarEvent) { + const color = this.colors[this.type(booking)][0]; + return this.isBookingForOtherUser(booking) ? `${color}80` : color; + } + + public isBookingForOtherUser = isBookingForOtherUser; + public bookedForLabel = bookedForLabel; + public viewBooking(bkn: CalendarEvent | Booking) { this._dialog.closeAll(); if (bkn instanceof CalendarEvent) { diff --git a/apps/workplace/src/app/schedule/schedule-filter-card.component.ts b/apps/workplace/src/app/schedule/schedule-filter-card.component.ts index 21aee8dc7f..73aedfe66c 100644 --- a/apps/workplace/src/app/schedule/schedule-filter-card.component.ts +++ b/apps/workplace/src/app/schedule/schedule-filter-card.component.ts @@ -5,8 +5,8 @@ import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; import { - BOOKING_TYPE_COLORS, Booking, + BOOKING_TYPE_COLORS, CalendarEvent, SettingsService, } from '@placeos/common'; @@ -15,7 +15,10 @@ import { SettingsToggleComponent, TranslatePipe, } from '@placeos/components'; -import { ScheduleStateService } from './schedule-state.service'; +import { + isBookingForOtherUser, + ScheduleStateService, +} from './schedule-state.service'; @Component({ selector: 'schedule-filter-card', @@ -56,6 +59,20 @@ import { ScheduleStateService } from './schedule-state.service'; } } + +
+
+ perm_contact_calendar +
+
Bookings for Others
+
+ {{ counts()['bookings-for-others'] || 0 }} +
+
+
} } + @if (filters()?.show_bookings_for_others) { +
+ perm_contact_calendar +
Bookings for Others
+
+ {{ counts()['bookings-for-others'] || 0 }} +
+ +
+ }
} } + @if (filters()?.show_bookings_for_others) { +
+ perm_contact_calendar +
Bookings for Others
+
+ {{ counts()['bookings-for-others'] || 0 }} +
+ +
+ }
`, styles: [``], @@ -117,6 +166,10 @@ export class ScheduleFiltersComponent { } else { const type = bkn.booking_type; mapping[type] = (mapping[type] || 0) + 1; + if (isBookingForOtherUser(bkn)) { + mapping['bookings-for-others'] = + (mapping['bookings-for-others'] || 0) + 1; + } } } return mapping; @@ -136,6 +189,8 @@ export class ScheduleFiltersComponent { ]; public readonly toggleType = (t, c = false) => this._state.toggleType(t, c); + public readonly toggleBookingsForOthers = () => + this._state.toggleBookingsForOthers(); public hasFeature(feature: string | string[]) { const features = this.features() || []; diff --git a/apps/workplace/src/app/schedule/schedule-sidebar.component.ts b/apps/workplace/src/app/schedule/schedule-sidebar.component.ts index cb34b72594..1406cca913 100644 --- a/apps/workplace/src/app/schedule/schedule-sidebar.component.ts +++ b/apps/workplace/src/app/schedule/schedule-sidebar.component.ts @@ -32,6 +32,7 @@ import { } from '@placeos/form-fields'; import { endOfDay, isSameDay, startOfDay } from 'date-fns'; import { + isBookingForOtherUser, ScheduleOptions, ScheduleStateService, } from './schedule-state.service'; @@ -132,6 +133,22 @@ import { } } + +
+
+ perm_contact_calendar +
+
+ Bookings for Others +
+
+ {{ counts()['bookings-for-others'] || 0 }} +
+
+
`, @@ -170,6 +187,8 @@ export class ScheduleSidebarComponent extends AsyncHandler implements OnInit { return end ? endOfDay(end) : null; }); public readonly toggleType = (t) => this._state.toggleType(t); + public readonly toggleBookingsForOthers = () => + this._state.toggleBookingsForOthers(); public readonly setDate = (d) => this._state.setDate(d); public readonly bookings = input<(Booking | CalendarEvent)[]>([]); public readonly view = input<'day' | 'week' | 'list'>('day'); @@ -225,6 +244,10 @@ export class ScheduleSidebarComponent extends AsyncHandler implements OnInit { } else { const type = bkn.booking_type; mapping[type] = (mapping[type] || 0) + 1; + if (isBookingForOtherUser(bkn)) { + mapping['bookings-for-others'] = + (mapping['bookings-for-others'] || 0) + 1; + } } } return mapping; diff --git a/apps/workplace/src/app/schedule/schedule-state.service.ts b/apps/workplace/src/app/schedule/schedule-state.service.ts index 0b8b59f087..9f8750b0ed 100644 --- a/apps/workplace/src/app/schedule/schedule-state.service.ts +++ b/apps/workplace/src/app/schedule/schedule-state.service.ts @@ -67,6 +67,34 @@ export interface ScheduleOptions { period: 'day' | 'week' | 'month' | 'range'; } +export interface ScheduleFilters { + shown_types: string[]; + show_bookings_for_others: boolean; +} + +export function isBookingForOtherUser( + item: Booking | CalendarEvent, + current_email = currentUser()?.email, +) { + if (!(item instanceof Booking)) return false; + const current_user_email = current_email?.toLowerCase(); + const booked_by_email = item.booked_by_email?.toLowerCase(); + const user_email = item.user_email?.toLowerCase(); + return ( + !!current_user_email && + booked_by_email === current_user_email && + !!user_email && + user_email !== current_user_email + ); +} + +export function bookedForLabel(item: Booking | CalendarEvent) { + if (!(item instanceof Booking)) return ''; + return ( + `${item.user_name || ''}`.trim() || `${item.user_email || ''}`.trim() + ); +} + function deduplicateEventsByIcalUid( events_by_source: CalendarEvent[][], ): CalendarEvent[] { @@ -103,7 +131,7 @@ export class ScheduleStateService extends AsyncHandler { private _event_sources = signal(['api']); private _loading = signal(false); private _options = signal({ period: 'day' }); - private _filters = signal({ + private _filters = signal({ shown_types: [ 'event', 'desk', @@ -112,6 +140,7 @@ export class ScheduleStateService extends AsyncHandler { 'locker', 'group-event', ], + show_bookings_for_others: false, }); private _date = signal(Date.now()); private _end_date = signal(null); @@ -301,6 +330,12 @@ export class ScheduleStateService extends AsyncHandler { ) { return false; } + if ( + this.isBookingForOtherUser(_) && + !filters?.show_bookings_for_others + ) { + return false; + } if ( _.extension_data?.shared_event && !filters?.shown_types?.includes('group-event') @@ -459,7 +494,7 @@ export class ScheduleStateService extends AsyncHandler { } public setType(name: string, state: boolean) { - const filters = this._filters() || { shown_types: [] }; + const filters = this._filters(); const { shown_types } = filters; if (shown_types.includes(name) === state) return; const new_types = state @@ -468,8 +503,22 @@ export class ScheduleStateService extends AsyncHandler { this._filters.set({ ...filters, shown_types: new_types }); } + public setBookingsForOthers(state: boolean) { + const filters = this._filters(); + if (filters.show_bookings_for_others === state) return; + this._filters.set({ ...filters, show_bookings_for_others: state }); + } + + public isBookingForOtherUser(item: Booking | CalendarEvent) { + return isBookingForOtherUser(item); + } + + public toggleBookingsForOthers() { + this.setBookingsForOthers(!this._filters().show_bookings_for_others); + } + public async toggleType(name: string, clear = false) { - const filters = this._filters() || { shown_types: [] }; + const filters = this._filters(); const { shown_types } = filters; if (shown_types && (shown_types.includes(name) || clear)) { this._filters.set({ diff --git a/apps/workplace/src/app/schedule/schedule-week-view.component.ts b/apps/workplace/src/app/schedule/schedule-week-view.component.ts index 355ffe7763..2597ba5d6c 100644 --- a/apps/workplace/src/app/schedule/schedule-week-view.component.ts +++ b/apps/workplace/src/app/schedule/schedule-week-view.component.ts @@ -37,7 +37,11 @@ import { startOfDay, startOfWeek, } from 'date-fns'; -import { ScheduleStateService } from './schedule-state.service'; +import { + bookedForLabel, + isBookingForOtherUser, + ScheduleStateService, +} from './schedule-state.service'; interface Weekday { id: string; @@ -100,7 +104,7 @@ interface Weekday { class="bg-base-100 w-full rounded-lg border p-2 text-left text-black" [style.border-color]="colors[type(bkn)][1]" [style.background-color]=" - colors[type(bkn)][0] + backgroundColor(bkn) " (click)="viewBooking(bkn)" [matTooltip]=" @@ -113,6 +117,10 @@ interface Weekday { ? ' ' + visitorName(bkn) : '') + + (isBookingForOtherUser(bkn) + ? ' +for ' + bookedForLabel(bkn) + : '') + ' ' + ($any(bkn).user_name || @@ -158,6 +166,28 @@ interface Weekday { {{ visitorName(bkn) }} } + @if (isBookingForOtherUser(bkn)) { +
+ Booked by + {{ + $any(bkn).booked_by_name || + ( + $any(bkn) + .booked_by_email + | user + | async + )?.name || + $any(bkn).booked_by_email + }} +
+
+ for {{ bookedForLabel(bkn) }} +
+ }
{{ bkn.date | date: 'shortTime' }}
@@ -320,6 +350,14 @@ export class ScheduleWeekViewComponent { return bookingLocationString(booking, this._org); } + public backgroundColor(booking: Booking | CalendarEvent) { + const color = this.colors[this.type(booking)][0]; + return this.isBookingForOtherUser(booking) ? `${color}80` : color; + } + + public isBookingForOtherUser = isBookingForOtherUser; + public bookedForLabel = bookedForLabel; + public viewBooking(bkn: CalendarEvent | Booking) { this._dialog.closeAll(); if (bkn instanceof CalendarEvent) { diff --git a/apps/workplace/src/environments/settings.schema.json b/apps/workplace/src/environments/settings.schema.json index 3856e1c15c..133bb45401 100644 --- a/apps/workplace/src/environments/settings.schema.json +++ b/apps/workplace/src/environments/settings.schema.json @@ -132,6 +132,14 @@ "force_current_user_for_booking_rules": { "type": "boolean", "description": "Whether booking rule checks should always use the signed-in user as the host, even when booking on behalf of someone else." + }, + "allow_booking_with_reserved_resource": { + "type": "boolean", + "description": "Whether users with a resource (desk/parking/locker) reserved (assigned) to them are allowed to book another resource of that type. By default these users are blocked. Override per type under app.s.* (e.g. app.desks.*)." + }, + "prevent_self_booking_if_assigned_resource": { + "type": "boolean", + "description": "When booking with a reserved resource is allowed, still prevent users from booking a resource of that type for themselves while one is assigned to them. Bookings on behalf of other users remain allowed. Checked only at submission. Override per type under app.s.*." } } }, @@ -173,14 +181,6 @@ "can_set_host": { "type": "boolean", "description": "Whether user is allowed to make desk bookings for others" - }, - "allow_booking_with_reserved_desk": { - "type": "boolean", - "description": "Whether users with a desk reserved (assigned) to them are allowed to book another desk. By default these users are blocked from the desk booking flow." - }, - "prevent_self_booking_if_assigned_desk": { - "type": "boolean", - "description": "Legacy setting. Forces users with an assigned desk to be prevented from booking a desk for themselves, overriding allow_booking_with_reserved_desk. Bookings for other users are still allowed." } } }, diff --git a/apps/workplace/src/environments/settings.ts b/apps/workplace/src/environments/settings.ts index 43af553fd7..93ddf0c746 100644 --- a/apps/workplace/src/environments/settings.ts +++ b/apps/workplace/src/environments/settings.ts @@ -217,6 +217,10 @@ const app = { allowed_daily_visitor_count: 100, multiple_visitors: true, force_current_user_for_booking_rules: false, + // Applies to any assignable resource type (desk/parking/locker). + // Override per type under `app.s.*` (e.g. `app.desks.*`). + allow_booking_with_reserved_resource: false, + prevent_self_booking_if_assigned_resource: false, }, desks: { can_book_lockers: true, @@ -226,8 +230,6 @@ const app = { allow_time_changes: true, allow_all_day: true, auto_allocation: false, - allow_booking_with_reserved_desk: false, - prevent_self_booking_if_assigned_desk: false, show_calendar_links: true, allow_recurrence: true, hide_map: false, diff --git a/apps/workplace/src/tests/schedule/schedule-filter-card.component.spec.ts b/apps/workplace/src/tests/schedule/schedule-filter-card.component.spec.ts index 6aa14cb495..c650192d95 100644 --- a/apps/workplace/src/tests/schedule/schedule-filter-card.component.spec.ts +++ b/apps/workplace/src/tests/schedule/schedule-filter-card.component.spec.ts @@ -1,3 +1,4 @@ +import { signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatBottomSheetRef } from '@angular/material/bottom-sheet'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -5,7 +6,6 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; import { SettingsService } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { MockComponent, MockProvider } from 'ng-mocks'; -import { BehaviorSubject } from 'rxjs'; import { ScheduleFilterCardComponent } from '../../app/schedule/schedule-filter-card.component'; import { ScheduleStateService } from '../../app/schedule/schedule-state.service'; @@ -15,8 +15,12 @@ describe('ScheduleFilterCardComponent', () => { component: ScheduleFilterCardComponent, providers: [ MockProvider(ScheduleStateService, { - filters: new BehaviorSubject({}), + filters: signal({ + shown_types: [], + show_bookings_for_others: false, + }), toggleType: jest.fn(), + toggleBookingsForOthers: jest.fn(), setDate: jest.fn(), } as any), MockProvider(MatBottomSheetRef, { dismiss: jest.fn() }), diff --git a/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts b/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts index 33f29a853e..bd0fd92c1a 100644 --- a/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts +++ b/apps/workplace/src/tests/schedule/schedule-state.service.spec.ts @@ -46,6 +46,7 @@ import { queryBookings, } from '@placeos/bookings'; import { queryEvents, requestSpacesForZone } from '@placeos/events'; +import { isBookingForOtherUser } from '../../app/schedule/schedule-state.service'; describe('ScheduleStateService', () => { let spectator: SpectatorService; @@ -77,9 +78,12 @@ describe('ScheduleStateService', () => { MockProvider(EventFormService, event_form), MockProvider(BookingFormService, { newForm: jest.fn(), - model: Object.assign(jest.fn(() => ({})), { - update: jest.fn(), - }), + model: Object.assign( + jest.fn(() => ({})), + { + update: jest.fn(), + }, + ), } as any), MockProvider(SpacesService, spaces), { @@ -100,6 +104,7 @@ describe('ScheduleStateService', () => { }); afterEach(() => { + jest.restoreAllMocks(); jest.useRealTimers(); }); @@ -116,9 +121,9 @@ describe('ScheduleStateService', () => { asset_name: 'space-1', }), ]; - const [allocated] = ( - spectator.service as any - )._resolveParkingNames(list); + const [allocated] = (spectator.service as any)._resolveParkingNames( + list, + ); expect(allocated.asset_name).toBe('Bay 1'); }); @@ -235,6 +240,62 @@ describe('ScheduleStateService', () => { expect((spectator.service as any)._canLoadEvents()).toBe(false); }); + it('should hide bookings made by me for others by default', () => { + const settings = spectator.inject(SettingsService) as any; + settings.get.mockImplementation((key: string) => + key === 'app.features' ? ['desks'] : undefined, + ); + const mine = new Booking({ + id: 'mine', + booking_type: 'desk', + user_email: 'me@example.com', + booked_by_email: 'me@example.com', + }); + const for_other = new Booking({ + id: 'other', + booking_type: 'desk', + user_email: 'other@example.com', + booked_by_email: 'me@example.com', + }); + jest.spyOn( + spectator.service, + 'isBookingForOtherUser', + ).mockImplementation((item) => item === for_other); + (spectator.service as any)._desks.set([mine, for_other]); + + expect(spectator.service.filtered_bookings()).toEqual([mine]); + + spectator.service.toggleBookingsForOthers(); + + expect(spectator.service.filtered_bookings()).toEqual([ + mine, + for_other, + ]); + }); + + it('should identify bookings made by the current user for someone else', () => { + expect( + isBookingForOtherUser( + new Booking({ + booking_type: 'desk', + user_email: 'other@example.com', + booked_by_email: 'me@example.com', + }), + 'me@example.com', + ), + ).toBe(true); + expect( + isBookingForOtherUser( + new Booking({ + booking_type: 'desk', + user_email: 'me@example.com', + booked_by_email: 'me@example.com', + }), + 'me@example.com', + ), + ).toBe(false); + }); + it('should share identical in-flight booking queries', async () => { const date = new Date(2026, 5, 22, 9).valueOf(); diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index a3fe07447d..9c6b56785f 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -20,7 +20,9 @@ import { BookingClash, BookingRuleset, BookingType, + currentUserIsLoaded, currentUser, + currentUserLoaded, Desk, firstValueWhere, flatten, @@ -52,6 +54,7 @@ import { openRecurringClashModal } from 'libs/components/src/lib/recurring-clash import { CalendarService } from 'libs/events/src/lib/calendar.service'; import { BookingLinkModalComponent } from './booking-link-modal.component'; import { + bookingAttachments, bookingFormValue, type BookingFormValue, findNearbyFeature, @@ -341,7 +344,7 @@ export class BookingFormService extends AsyncHandler { // The override count can be satisfied by placeholder `{}` building // settings before `loadBuildingData` populates them, so also wait // for the active building's metadata to actually land. Otherwise - // building/region-level settings (e.g. allow_booking_with_reserved_desk) + // building/region-level settings (e.g. allow_booking_with_reserved_resource) // read as their defaults during the load window. this._org.active_building_loaded() && overrides.length >= required_overrides @@ -602,6 +605,43 @@ export class BookingFormService extends AsyncHandler { ); } + /** + * Whether the current user has a resource of `type` reserved (assigned) to + * them. Only desk/parking/locker support assignment; any other type resolves + * to `false` so it is never blocked by the reserved-resource restriction. + */ + private async _computeHasAssignedResource( + type: BookingType, + ): Promise { + if (type === 'desk') return this._computeHasAssignedDesk(); + const email = currentUser()?.email?.toLowerCase(); + if (!email) return false; + const resources = await this._loadRawResourcesForType(type).catch( + () => [] as BookingAsset[], + ); + return resources.some( + (resource) => + (resource as any).assigned_to?.toLowerCase() === email, + ); + } + + /** + * Load the resource list for `type` without the loading-message side effects + * of `_loadResourcesForType`, so it can be used for background checks (e.g. + * detecting an assigned resource during submission). + */ + private _loadRawResourcesForType( + type: BookingType, + ): Promise { + switch (type) { + case 'parking': + return this.loadParkingResources(); + case 'locker': + return this._loadLockerResources(); + } + return Promise.resolve([]); + } + private async _computeAvailableResources( options: BookingFlowOptions, resources: BookingAsset[], @@ -764,6 +804,7 @@ export class BookingFormService extends AsyncHandler { cleanObject( { ...booking.extension_data, + attachments: bookingAttachments(booking), ...booking, _in_progress: booking.state === 'started' || @@ -916,6 +957,10 @@ export class BookingFormService extends AsyncHandler { } public resetForm() { + if (!currentUserIsLoaded()) { + currentUserLoaded().then(() => this.resetForm()); + return; + } if (!sessionStorage.getItem(STORAGE_KEYS.booking_form)) { return this.newForm(this._options().type); } @@ -931,6 +976,7 @@ export class BookingFormService extends AsyncHandler { { ...(booking || {}), ...(booking?.extension_data || {}), + attachments: bookingAttachments(booking), _in_progress: booking?.state === 'started', }, [null, undefined, ''], @@ -1001,6 +1047,7 @@ export class BookingFormService extends AsyncHandler { ...data, ...(booking || {}), ...(booking?.extension_data || {}), + attachments: bookingAttachments(booking), _in_progress: booking?.state === 'started', }, [null, undefined, ''], @@ -1144,7 +1191,7 @@ export class BookingFormService extends AsyncHandler { const selected_booking_type = value.booking_type || this._options().type; if (ignore_check) { - await this._checkAssignedDeskRestriction( + await this._checkAssignedResourceRestriction( host, selected_booking_type, ); @@ -1266,8 +1313,8 @@ export class BookingFormService extends AsyncHandler { : []; const result = await saveBooking( new Booking({ - ...this._options(), - ...value, + type: this._options().type, + ...formBookingData(value), description: value.booking_type === 'visitor' ? value.description || value.title || value.asset_name @@ -1335,7 +1382,16 @@ export class BookingFormService extends AsyncHandler { } public setting(key: string) { - const { type } = this._options(); + return this.settingForType(this._options().type, key); + } + + /** + * Resolve a setting for a specific booking type. Prefer this over `setting()` + * when the relevant type is known (e.g. at submission), since this service is + * a shared singleton and `setting()` resolves against whichever flow's + * `_options().type` happens to be active. + */ + public settingForType(type: string, key: string) { return ( this._settings.get(`app.${type}.${key}`) ?? this._settings.get(`app.${type}s.${key}`) ?? @@ -1877,7 +1933,7 @@ export class BookingFormService extends AsyncHandler { ); return saveBooking( new Booking({ - ...form_data, + ...formBookingData(form_data), id, parent_id: '', asset_id: group_name, @@ -2100,29 +2156,47 @@ export class BookingFormService extends AsyncHandler { } /** - * Whether users with a desk reserved (assigned) to them are allowed to - * book another desk for themselves. Blocked by default; enable with the - * `allow_booking_with_reserved_desk` setting. The legacy - * `prevent_self_booking_if_assigned_desk` setting forces blocking. + * Whether a user with a resource of `type` reserved (assigned) to them is + * allowed to book additional resources of that type at all. When `false` + * (default) they are fully blocked from booking another, including on + * behalf of others. */ - public canBookWithReservedDesk() { + public allowsBookingWithReservedResource(type: BookingType) { return ( - this.setting('allow_booking_with_reserved_desk') === true && - this.setting('prevent_self_booking_if_assigned_desk') !== true + this.settingForType( + type, + 'allow_booking_with_reserved_resource', + ) === true ); } - private async _checkAssignedDeskRestriction( + /** + * Enforce the reserved-resource restriction for any assignable resource type + * (desk/parking/locker). Only ever concerns the current user's own bookings: + * booking on behalf of others is always permitted. + * + * `prevent_self_booking_if_assigned_resource` is deliberately evaluated here, + * at submission, and nowhere else. UI gating uses + * `allowsBookingWithReservedResource()` (the master allow) so a self-booking + * restriction never blanket-blocks the form for booking on behalf of others. + */ + private async _checkAssignedResourceRestriction( user_email: string, type: BookingType, ) { - if (type !== 'desk') return true; - if (this.canBookWithReservedDesk()) return true; - if (user_email?.toLowerCase() !== currentUser()?.email?.toLowerCase()) { - return true; - } - if (await this._computeHasAssignedDesk()) { - throw 'You have an assigned desk and cannot book another desk.'; + const is_self = + !user_email || + user_email.toLowerCase() === currentUser()?.email?.toLowerCase(); + if (!is_self) return true; + const self_booking_allowed = + this.allowsBookingWithReservedResource(type) && + this.settingForType( + type, + 'prevent_self_booking_if_assigned_resource', + ) !== true; + if (self_booking_allowed) return true; + if (await this._computeHasAssignedResource(type)) { + throw `You have an assigned ${type} and cannot book another ${type}.`; } return true; } @@ -2134,7 +2208,7 @@ export class BookingFormService extends AsyncHandler { ) { if (!user_email) throw i18n('BOOKINGS.NO_USER'); if (type === 'group-event') return true; - await this._checkAssignedDeskRestriction(user_email, type); + await this._checkAssignedResourceRestriction(user_email, type); const period = all_day ? this._allDayTimeRange(date) : { date, date_end: date + duration * 60 * 1000 }; diff --git a/libs/bookings/src/lib/booking.utilities.ts b/libs/bookings/src/lib/booking.utilities.ts index 2c5e0300a4..01b078f8be 100644 --- a/libs/bookings/src/lib/booking.utilities.ts +++ b/libs/bookings/src/lib/booking.utilities.ts @@ -265,13 +265,20 @@ export interface BookingFormValue { recurrence_end: any; recurrence_instances: any; notes: string; - p2_document_names: string[]; - attachments: any[]; + attachments: string[]; update_master: boolean; self_registered: boolean; is_assgined: boolean; } +export function bookingAttachments(booking: Booking = new Booking()): string[] { + booking = booking || new Booking(); + return [ + ...(booking.extension_data.attachments || []), + ...(booking.extension_data.p2_document_names || []), + ].filter((item) => !!item); +} + /** Build the raw booking form value from a booking. */ export function bookingFormValue( booking: Booking = new Booking(), @@ -346,8 +353,7 @@ export function bookingFormValue( recurrence_end: booking.recurrence_end ?? 0, recurrence_instances: booking.extension_data.recurrence_instances ?? [], notes: booking.extension_data.notes || '', - p2_document_names: booking.extension_data.p2_document_names || [], - attachments: booking.extension_data.attachments || [], + attachments: bookingAttachments(booking), update_master: false, self_registered: false, is_assgined: false, diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index f676bb091c..3fd57f8ed6 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -614,6 +614,71 @@ describe('BookingFormService', () => { ).toEqual(['PlaceOS P1 Parking', 'After Hours Parking']); }); + it('should only save form fields from booking extension data', async () => { + const save_booking = booking_mod.saveBooking as jest.Mock; + (spectator.inject(PaymentsService) as any).enabled = false; + save_booking.mockReset(); + save_booking.mockImplementation((booking: Booking) => + Promise.resolve(booking), + ); + spectator.service.newForm( + 'parking', + new Booking({ + booking_type: 'parking', + date: Date.now() + 60 * 60 * 1000, + duration: 60, + asset_id: 'unallocated-parking', + extension_data: { + notes: 'Needs access', + p2_document_names: ['Permit.pdf'], + attachments: ['https://example.com/permit.pdf'], + cost_code: 'CC-123', + resources: [{ id: 'resource-1' }], + level: { id: 'lvl-1' }, + }, + }), + ); + spectator.service.model.update( + (m) => + ({ + ...m, + booking_type: 'parking', + asset_id: 'unallocated-parking', + asset_name: 'Parking Request', + title: 'Parking Request', + cost_code: 'CC-123', + p2_document_names: ['Permit.pdf'], + resources: [{ id: 'resource-1' }], + level: { id: 'lvl-1' }, + extension_data: { + notes: 'Needs access', + p2_document_names: ['Permit.pdf'], + attachments: ['https://example.com/permit.pdf'], + cost_code: 'CC-123', + resources: [{ id: 'resource-1' }], + assets: [{ id: 'asset-1' }], + level: { id: 'lvl-1' }, + }, + }) as any, + ); + + await spectator.service.postForm(true); + + const extension_data = (save_booking.mock.calls[0][0] as Booking) + .extension_data; + expect(save_booking).toHaveBeenCalledTimes(1); + expect(extension_data.notes).toBe('Needs access'); + expect(extension_data.attachments).toEqual([ + 'https://example.com/permit.pdf', + 'Permit.pdf', + ]); + expect(extension_data.p2_document_names).toBeUndefined(); + expect(extension_data.cost_code).toBeUndefined(); + expect(extension_data.resources).toBeUndefined(); + expect(extension_data.assets).toEqual([]); + expect(extension_data.level).toBeUndefined(); + }); + it('should recompute parking request start and end when stale booking fields are present', async () => { const save_booking = booking_mod.saveBooking as jest.Mock; (spectator.inject(PaymentsService) as any).enabled = false; @@ -710,7 +775,7 @@ describe('BookingFormService', () => { const save_booking = booking_mod.saveBooking as jest.Mock; (spectator.inject(PaymentsService) as any).enabled = false; get.mockImplementation((key: string) => { - if (key === 'app.desks.prevent_self_booking_if_assigned_desk') { + if (key === 'app.desks.prevent_self_booking_if_assigned_resource') { return true; } return undefined; @@ -768,7 +833,7 @@ describe('BookingFormService', () => { const save_booking = booking_mod.saveBooking as jest.Mock; (spectator.inject(PaymentsService) as any).enabled = false; get.mockImplementation((key: string) => { - if (key === 'app.desks.prevent_self_booking_if_assigned_desk') { + if (key === 'app.desks.prevent_self_booking_if_assigned_resource') { return true; } return undefined; @@ -884,7 +949,7 @@ describe('BookingFormService', () => { const save_booking = booking_mod.saveBooking as jest.Mock; (spectator.inject(PaymentsService) as any).enabled = false; get.mockImplementation((key: string) => { - if (key === 'app.desks.allow_booking_with_reserved_desk') { + if (key === 'app.desks.allow_booking_with_reserved_resource') { return true; } return undefined; @@ -941,10 +1006,10 @@ describe('BookingFormService', () => { const save_booking = booking_mod.saveBooking as jest.Mock; (spectator.inject(PaymentsService) as any).enabled = false; get.mockImplementation((key: string) => { - if (key === 'app.desks.allow_booking_with_reserved_desk') { + if (key === 'app.desks.allow_booking_with_reserved_resource') { return true; } - if (key === 'app.desks.prevent_self_booking_if_assigned_desk') { + if (key === 'app.desks.prevent_self_booking_if_assigned_resource') { return true; } return undefined; @@ -997,6 +1062,146 @@ describe('BookingFormService', () => { expect(save_booking).not.toHaveBeenCalled(); }); + it('should resolve the reserved-desk allowance from desk settings regardless of the active flow type', () => { + const get = spectator.inject(SettingsService).get as jest.Mock; + get.mockImplementation((key: string) => { + if (key === 'app.desks.allow_booking_with_reserved_resource') { + return true; + } + return undefined; + }); + // Simulate another flow (e.g. parking) being active on the shared + // singleton. The desk allowance must remain stable so the reserved-desk + // block does not appear intermittently. + spectator.service.newForm('parking'); + expect(spectator.service.allowsBookingWithReservedResource('desk')).toBe(true); + spectator.service.newForm('desk'); + expect(spectator.service.allowsBookingWithReservedResource('desk')).toBe(true); + }); + + it('should keep the reserved-desk allowance on even when self-booking is prevented', () => { + const get = spectator.inject(SettingsService).get as jest.Mock; + get.mockImplementation((key: string) => { + if (key === 'app.desks.allow_booking_with_reserved_resource') { + return true; + } + if (key === 'app.desks.prevent_self_booking_if_assigned_resource') { + return true; + } + return undefined; + }); + // `prevent` blocks only the user's own bookings, and only at submit, so + // the master allowance (which drives UI gating) stays on and the form is + // not blanket-blocked for booking on behalf of others. + expect(spectator.service.allowsBookingWithReservedResource('desk')).toBe(true); + }); + + it('should resolve the reserved-resource allowance independently for each resource type', () => { + const get = spectator.inject(SettingsService).get as jest.Mock; + get.mockImplementation((key: string) => { + if (key === 'app.parking.allow_booking_with_reserved_resource') { + return true; + } + return undefined; + }); + expect( + spectator.service.allowsBookingWithReservedResource('parking'), + ).toBe(true); + expect( + spectator.service.allowsBookingWithReservedResource('desk'), + ).toBe(false); + expect( + spectator.service.allowsBookingWithReservedResource('locker'), + ).toBe(false); + }); + + it('should fall back to booking-level reserved-resource settings for any resource type', () => { + const get = spectator.inject(SettingsService).get as jest.Mock; + get.mockImplementation((key: string) => { + if (key === 'app.bookings.allow_booking_with_reserved_resource') { + return true; + } + return undefined; + }); + expect( + spectator.service.allowsBookingWithReservedResource('desk'), + ).toBe(true); + expect( + spectator.service.allowsBookingWithReservedResource('parking'), + ).toBe(true); + expect( + spectator.service.allowsBookingWithReservedResource('locker'), + ).toBe(true); + }); + + it('should allow desk bookings for others when self-booking is prevented for reserved-desk users', async () => { + const get = spectator.inject(SettingsService).get as jest.Mock; + const save_booking = booking_mod.saveBooking as jest.Mock; + (spectator.inject(PaymentsService) as any).enabled = false; + get.mockImplementation((key: string) => { + if (key === 'app.desks.allow_booking_with_reserved_resource') { + return true; + } + if (key === 'app.desks.prevent_self_booking_if_assigned_resource') { + return true; + } + return undefined; + }); + jest.mocked(ts_client.listChildMetadata).mockResolvedValue([ + { + metadata: { + desks: { + details: [ + { + id: 'assigned-desk', + assigned_to: currentUser().email, + }, + ], + }, + }, + zone: { id: 'lvl-1' }, + }, + ] as any); + save_booking.mockReset(); + save_booking.mockImplementation((booking: Booking) => + Promise.resolve(booking), + ); + spectator.service.newForm( + 'desk', + new Booking({ + booking_type: 'desk', + date: Date.now() + 60 * 60 * 1000, + duration: 60, + asset_id: 'desk-1', + }), + ); + spectator.service.model.update((m) => ({ + ...m, + user: { + email: 'other.user@example.com', + name: 'Other User', + id: 'other-user', + } as any, + asset_id: 'desk-1', + asset_name: 'Desk 1', + resources: [ + { + id: 'desk-1', + name: 'Desk 1', + zone: { id: 'lvl-1', parent_id: 'bld-1' }, + features: [], + }, + ], + })); + + await spectator.service.postForm(true); + + expect(save_booking).toHaveBeenCalledTimes(1); + expect((save_booking.mock.calls[0][0] as Booking).user_email).toBe( + 'other.user@example.com', + ); + }); + it('should clear saved host changes after a permission error', async () => { const save_booking = booking_mod.saveBooking as jest.Mock; const current_user = currentUser(); diff --git a/libs/common/src/lib/placeos.service.ts b/libs/common/src/lib/placeos.service.ts index 722a407864..bd48581eb3 100644 --- a/libs/common/src/lib/placeos.service.ts +++ b/libs/common/src/lib/placeos.service.ts @@ -234,9 +234,13 @@ export class PlaceOS_Service extends AsyncHandler { setupCache(this._cache); log('APP', 'MOCKS:', _mocks); if (_mocks) { + // Mirror setupPlace's mock detection — the URL param enables mocks + // on first load before setupPlace persists it to localStorage. const mocks_enabled = - localStorage.getItem('mock') === 'true' || - location.origin.includes('demo.place.tech'); + !location.href.includes('mock=false') && + (localStorage.getItem('mock') === 'true' || + location.href.includes('mock=true') || + location.origin.includes('demo.place.tech')); if (mocks_enabled) { setLoadingMessage('Initializing mocks...'); _mocks(); diff --git a/libs/common/src/lib/user-state.ts b/libs/common/src/lib/user-state.ts index 17025f62a3..16e99b16ab 100644 --- a/libs/common/src/lib/user-state.ts +++ b/libs/common/src/lib/user-state.ts @@ -4,7 +4,7 @@ import { BehaviorSubject, combineLatest, of, timer } from 'rxjs'; import { catchError, map, retry } from 'rxjs/operators'; import { isPublicMode } from './public-mode'; import { setDefaultCreator } from './types/event.class'; -import { EMPTY_USER, StaffUser } from './types/user.class'; +import { EMPTY_USER, isEmptyUser, StaffUser } from './types/user.class'; export { EMPTY_USER, isEmptyUser } from './types/user.class'; @@ -197,6 +197,27 @@ export function currentUser() { return _current_user.getValue() || EMPTY_USER; } +export function currentUserIsLoaded() { + if (!isEmptyUser(currentUser())) return true; + try { + return !!jest; + } catch { + return false; + } +} + +export function currentUserLoaded(): Promise { + const user = currentUser(); + if (currentUserIsLoaded()) return Promise.resolve(user); + return new Promise((resolve) => { + const sub = _current_user.subscribe((user) => { + if (isEmptyUser(user)) return; + sub.unsubscribe(); + resolve(user); + }); + }); +} + export function userSignal() { return user_signal; } diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index ed1811716d..41ec379adb 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -17,6 +17,8 @@ import { BookingRuleset, CalendarEvent, currentUser, + currentUserIsLoaded, + currentUserLoaded, filterResourcesFromRules, firstValueWhere, flatten, @@ -34,7 +36,6 @@ import { Space, unique, User, - userSignal, } from '@placeos/common'; import { showMetadata } from '@placeos/ts-client'; @@ -426,7 +427,7 @@ export class EventFormService extends AsyncHandler { } public async init() { - await firstValueWhere(userSignal(), (user) => !isEmptyUser(user)); + await currentUserLoaded(); setDefaultCreator(currentUser()); onFieldChange( this._model, @@ -633,6 +634,10 @@ export class EventFormService extends AsyncHandler { } public newForm(event = new CalendarEvent()) { + if (!currentUserIsLoaded()) { + currentUserLoaded().then(() => this.newForm(event)); + return; + } this._startNetwork(); this._calendar.loadCalendars(); this._loading.set(''); @@ -656,6 +661,10 @@ export class EventFormService extends AsyncHandler { } public resetForm() { + if (!currentUserIsLoaded()) { + currentUserLoaded().then(() => this.resetForm()); + return; + } this._model.set(eventFormValue(this._event() || new CalendarEvent())); this._form().reset(); } @@ -670,6 +679,10 @@ export class EventFormService extends AsyncHandler { } public loadForm() { + if (!currentUserIsLoaded()) { + currentUserLoaded().then(() => this.loadForm()); + return; + } this._startNetwork(); this._calendar.loadCalendars(); const event_data = JSON.parse( @@ -680,7 +693,11 @@ export class EventFormService extends AsyncHandler { const form_data = JSON.parse( sessionStorage.getItem('PLACEOS.event_form') || '{}', ); - this._model.update((m) => ({ ...m, ...(event as any), ...form_data })); + this._model.update((m) => ({ + ...m, + ...eventFormValue(event), + ...form_data, + })); } public clearForm() { @@ -917,7 +934,7 @@ export class EventFormService extends AsyncHandler { if (this._model().host !== host) ext.host_override = this._model().host; const value = this._model(); - const created_event = await this._performBooking( + let created_event = await this._performBooking( new CalendarEvent({ ...(this._model() as any), date: all_day_period.date, @@ -937,6 +954,19 @@ export class EventFormService extends AsyncHandler { }), query, ).catch(on_error); + const date_end = + all_day_period.date_end || + all_day_period.date + all_day_period.duration * 60 * 1000; + created_event = new CalendarEvent({ + ...created_event, + event_start: Math.floor(all_day_period.date / 1000), + event_end: Math.floor(date_end / 1000), + date: all_day_period.date, + duration: all_day_period.duration, + date_end, + resources: space_list, + system: space_list[0] || null, + }); // Create visitor bookings for external attendees const domain = (currentUser()?.email || '@').split('@')[1]; const visitors = this._model().attendees.filter( @@ -1016,7 +1046,7 @@ export class EventFormService extends AsyncHandler { 'PLACEOS.last_modified_event', JSON.stringify(created_event.toJSON()), ); - this.loadLastSuccess(); + this.last_success.set(created_event); return created_event; } catch (e) { this.removeLoadingTag(Tags.PostBooking); diff --git a/libs/events/src/tests/event-form.service.spec.ts b/libs/events/src/tests/event-form.service.spec.ts index 1a1e27ca31..7bdc3f5510 100644 --- a/libs/events/src/tests/event-form.service.spec.ts +++ b/libs/events/src/tests/event-form.service.spec.ts @@ -222,6 +222,126 @@ describe('EventFormService', () => { expect(perform_booking_spy).toHaveBeenCalled(); }); + it('should keep the submitted time when the saved event response is stale', async () => { + const stale_start = new Date(2028, 5, 15, 10, 0, 0, 0).valueOf(); + const submitted_start = new Date(2028, 5, 16, 16, 0, 0, 0).valueOf(); + const submitted_end = new Date(2028, 5, 16, 17, 0, 0, 0).valueOf(); + sessionStorage.setItem( + 'PLACEOS.last_modified_event', + JSON.stringify({ + id: 'previous-event', + title: 'Previous booking', + event_start: Math.floor(stale_start / 1000), + event_end: Math.floor((stale_start + 30 * 60 * 1000) / 1000), + }), + ); + jest.spyOn(service as any, '_performBooking').mockResolvedValue( + new CalendarEvent({ + id: 'event-1', + host: 'host@test.com', + organiser: { email: 'host@test.com' } as any, + creator: 'host@test.com', + title: 'Moved booking', + event_start: Math.floor(stale_start / 1000), + event_end: Math.floor((stale_start + 30 * 60 * 1000) / 1000), + attendees: [], + resources: [], + }), + ); + + service.newForm(); + service.model.update((m) => ({ + ...m, + host: 'host@test.com', + organiser: { email: 'host@test.com' } as any, + creator: 'host@test.com', + title: 'Moved booking', + date: submitted_start, + duration: 60, + date_end: submitted_end, + attendees: [], + resources: [], + })); + + const result = await service.postForm(true); + const last_success = JSON.parse( + sessionStorage.getItem('PLACEOS.last_modified_event'), + ); + + expect(result.date).toBe(submitted_start); + expect(result.date_end).toBe(submitted_end); + expect(service.last_success()?.id).toBe('event-1'); + expect(service.last_success()?.date).toBe(submitted_start); + expect(service.last_success()?.date_end).toBe(submitted_end); + expect(last_success.event_start).toBe( + Math.floor(submitted_start / 1000), + ); + expect(last_success.event_end).toBe(Math.floor(submitted_end / 1000)); + }); + + it('should post the selected time after reloading a new meeting form', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2028, 5, 15, 13, 25, 0, 0)); + try { + const submitted_start = new Date( + 2028, + 5, + 16, + 16, + 0, + 0, + 0, + ).valueOf(); + const submitted_end = new Date( + 2028, + 5, + 16, + 17, + 0, + 0, + 0, + ).valueOf(); + const perform_booking_spy = jest + .spyOn(service as any, '_performBooking') + .mockResolvedValue( + new CalendarEvent({ + id: 'event-1', + title: 'Selected slot', + attendees: [], + resources: [], + }), + ); + sessionStorage.setItem( + 'PLACEOS.event_form', + JSON.stringify({ + host: 'host@test.com', + organiser: { email: 'host@test.com' }, + creator: 'host@test.com', + title: 'Selected slot', + date: submitted_start, + duration: 60, + date_end: submitted_end, + attendees: [], + resources: [], + }), + ); + + service.loadForm(); + await service.postForm(true); + const posted_event = perform_booking_spy.mock.calls[0][0]; + const posted_json = posted_event.toJSON(); + + expect(posted_json.event_start).toBe( + Math.floor(submitted_start / 1000), + ); + expect(posted_json.event_end).toBe( + Math.floor(submitted_end / 1000), + ); + } finally { + jest.useRealTimers(); + } + }); + it('should use the original calendar when changing host on an existing room booking', async () => { const current_user = currentUser(); const new_host = 'new.host@example.com'; diff --git a/libs/form-fields/src/lib/user-search-field.component.ts b/libs/form-fields/src/lib/user-search-field.component.ts index c2fe55b956..f086be9099 100644 --- a/libs/form-fields/src/lib/user-search-field.component.ts +++ b/libs/form-fields/src/lib/user-search-field.component.ts @@ -25,7 +25,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { AsyncHandler, settingSignal, User } from '@placeos/common'; +import { AsyncHandler, EMPTY_USER, settingSignal, User } from '@placeos/common'; import { authority, queryUsers, showUser } from '@placeos/ts-client'; import { @@ -206,7 +206,11 @@ export class UserSearchFieldComponent public readonly user = signal(null); public readonly selected_user = computed(() => { const term = this.search_term() as string | User; - return term && typeof term !== 'string' ? term : null; + return term && + typeof term !== 'string' && + term.email !== EMPTY_USER.email + ? term + : null; }); /** Whether form field is disabled */ @@ -252,20 +256,24 @@ export class UserSearchFieldComponent private readonly _search = resource({ params: () => ({ term: this._debounced_term.value() }), loader: async ({ params: { term } }): Promise => { - if (term && typeof term !== 'string') return [term as User]; + if (term && typeof term !== 'string') { + const user = term as User; + return user.email === EMPTY_USER.email ? [] : [user]; + } if (term === this.user()?.name) return [this.user()]; if (this.disable_search()) return []; const s = `${term || ''}`.toLowerCase(); if (this.options()?.length) { return this.options().filter( (_) => - _.name.toLowerCase().includes(s) || - _.email.toLowerCase().includes(s), + _.email !== EMPTY_USER.email && + (_.name.toLowerCase().includes(s) || + _.email.toLowerCase().includes(s)), ); } if (s.length <= 2) return []; const list = await this.query_fn()(s).catch(() => [] as User[]); - return list.filter((_) => !!_); + return list.filter((_) => !!_ && _.email !== EMPTY_USER.email); }, }); public readonly search_results = computed(() => this._search.value() ?? []); @@ -327,7 +335,9 @@ export class UserSearchFieldComponent } public displayFn(user: User): string { - return user && user.name ? user.name : ''; + return user && user.email !== EMPTY_USER.email && user.name + ? user.name + : ''; } public stopEvent(event: Event) { diff --git a/libs/form-fields/src/tests/user-search-field.component.spec.ts b/libs/form-fields/src/tests/user-search-field.component.spec.ts index c64b81af77..4c806d2e65 100644 --- a/libs/form-fields/src/tests/user-search-field.component.spec.ts +++ b/libs/form-fields/src/tests/user-search-field.component.spec.ts @@ -7,7 +7,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; import { MockComponent, MockProvider } from 'ng-mocks'; -import { User } from '@placeos/common'; +import { EMPTY_USER, User } from '@placeos/common'; import { showUser } from '@placeos/ts-client'; import { generateMockUser } from '@placeos/users'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -148,6 +148,43 @@ describe('UserSearchFieldComponent', () => { expect(spectator.component.selected_user()).toEqual(user); })); + it('should not display the empty user', async () => { + const empty_user = EMPTY_USER as User; + spectator.component.writeValue(empty_user); + spectator.detectChanges(); + + expect(spectator.component.selected_user()).toBeNull(); + expect(spectator.component.displayFn(empty_user)).toBe(''); + await new Promise((r) => setTimeout(r, 350)); // debounce window + spectator.detectChanges(); + await spectator.fixture.whenStable(); + expect(spectator.component.search_results()).toEqual([]); + + const spec = createComponent({ + props: { + query_fn: () => + Promise.resolve([ + empty_user, + new User({ + id: '1', + name: 'Real User', + email: 'real@example.com', + }), + ]), + }, + }); + + spec.component.search_term.set('real' as any); + spec.detectChanges(); + await new Promise((r) => setTimeout(r, 350)); // debounce window + spec.detectChanges(); + await spec.fixture.whenStable(); + + expect(spec.component.search_results()).toEqual([ + expect.objectContaining({ name: 'Real User' }), + ]); + }); + it('should show the selected user after selecting a user', fakeAsync(() => { const user = new User(generateMockUser()); @@ -231,6 +268,9 @@ describe('UserSearchFieldComponent', () => { spectator.tick(1); expect(blur_spy).toHaveBeenCalled(); + expect(spectator.component.selected_user()?.name).toBe( + 'External Person', + ); })); it('should select all text when the input is focused', fakeAsync(() => {