diff --git a/docs/settings/workplace.md b/docs/settings/workplace.md index a634e0b8a3..f32ebfaeb3 100644 --- a/docs/settings/workplace.md +++ b/docs/settings/workplace.md @@ -177,7 +177,7 @@ These apply to all resource booking flows (desks, parking, lockers). Most can be | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `bookings.no_approval` | boolean | `false` | Skip the approval process — new bookings are created already approved. | +| `bookings.no_approval` | boolean | `false` | Create new bookings already approved. Only an admin or support user's bookings are sent approved; staff-api refuses `approved` from other users, whose bookings are left for an approver or the auto-approval driver. | | `bookings.all_day_default` | boolean | `false` | Turn the "all day" option on by default for new bookings. | | `bookings.allow_all_day` | boolean | – | Make the "all day" option available in resource booking flows. | | `bookings.allowed_daily_visitor_count` | number | `100` | Maximum number of visitor invites allowed for a single day. | diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 3f6c9d6add..1022c8a660 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -25,6 +25,7 @@ import { BookingRuleset, BookingType, currentUser, + currentUserCanApprove, currentUserIsLoaded, currentUserLoaded, Desk, @@ -1498,7 +1499,8 @@ export class BookingFormService extends AsyncHandler { user_email: value.user?.email || value.user_email, extension_data: buildBookingExtensionData(value, group_members), approved: - this._settings.get('app.bookings.no_approval') === true, + this._settings.get('app.bookings.no_approval') === true && + currentUserCanApprove(), zones: unique([...zones, ...(value.zones || [])]).filter( (_) => _, ), @@ -2162,7 +2164,8 @@ export class BookingFormService extends AsyncHandler { user_email: form.user?.email || form.user_email, user_id: form.user?.id || form.user_id, approved: - this._settings.get('app.bookings.no_approval') === true, + this._settings.get('app.bookings.no_approval') === true && + currentUserCanApprove(), zones, extension_data: { ...formExtensionData(form.extension_data), diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index 6cd01619f0..074a227070 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -932,6 +932,52 @@ describe('BookingFormService', () => { expect(spectator.service.model().booking_type).toBe('desk'); }); + it('should leave approval to the backend for a standard user when approval is skipped', async () => { + (spectator.inject(PaymentsService) as any).enabled = false; + (spectator.inject(SettingsService).get as Mock).mockImplementation( + (key: string) => key === 'app.bookings.no_approval' || undefined, + ); + spectator.service.newForm('desk'); + spectator.service.model.update((m) => ({ + ...m, + asset_id: 'desk-1', + asset_name: 'Desk 1', + date: Date.now() + 60 * 60 * 1000, + duration: 60, + })); + + await spectator.service.postForm(true); + + expect((savedBookings()[0] as Booking).approved).toBe(false); + }); + + it('should send approved for a support user when approval is skipped', async () => { + (spectator.inject(PaymentsService) as any).enabled = false; + (spectator.inject(SettingsService).get as Mock).mockImplementation( + (key: string) => key === 'app.bookings.no_approval' || undefined, + ); + setCurrentUser( + new StaffUser({ + id: 'support-user', + email: 'support.user@example.com', + name: 'Support User', + groups: ['placeos_support'], + }), + ); + spectator.service.newForm('desk'); + spectator.service.model.update((m) => ({ + ...m, + asset_id: 'desk-1', + asset_name: 'Desk 1', + date: Date.now() + 60 * 60 * 1000, + duration: 60, + })); + + await spectator.service.postForm(true); + + expect((savedBookings()[0] as Booking).approved).toBe(true); + }); + it('should keep the host when editing a delegated visitor booking', async () => { (spectator.inject(PaymentsService) as any).enabled = false; spectator.service.newForm( diff --git a/libs/common/src/lib/user-state.ts b/libs/common/src/lib/user-state.ts index 8ef0fee080..2c9a14d6c3 100644 --- a/libs/common/src/lib/user-state.ts +++ b/libs/common/src/lib/user-state.ts @@ -324,6 +324,18 @@ export function currentUser() { return _current_user.getValue() || EMPTY_USER; } +/** + * Whether the current user may set a booking's approval state. staff-api + * accepts `approved` on create only from admin, support and zone manager + * users; zone management is not known client side + */ +export function currentUserCanApprove() { + const groups = currentUser()?.groups || []; + return ( + groups.includes('placeos_admin') || groups.includes('placeos_support') + ); +} + /** Override the current user store. Intended for tests seeding a loaded user. */ export function setCurrentUser(user: StaffUser) { _current_user.next(user); diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index 0c7a76526f..3e7215e2c4 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -17,6 +17,7 @@ import { BookingRuleset, CalendarEvent, currentUser, + currentUserCanApprove, currentUserIsLoaded, currentUserLoaded, DEFAULT_SETTINGS, @@ -1527,7 +1528,7 @@ export class EventFormService extends AsyncHandler { recurrence: event.recurrence, status: this._settings.get('app.bookings.no_approval') === - true + true && currentUserCanApprove() ? 'approved' : 'tentative', } as any), diff --git a/libs/events/src/tests/event-form.service.spec.ts b/libs/events/src/tests/event-form.service.spec.ts index 9b4a03fbff..cb82d103c7 100644 --- a/libs/events/src/tests/event-form.service.spec.ts +++ b/libs/events/src/tests/event-form.service.spec.ts @@ -117,6 +117,69 @@ describe('EventFormService', () => { sessionStorage.clear(); }); + describe('approval of native room bookings when approval is skipped', () => { + const performBooking = (service_under_test: EventFormService) => + ( + service_under_test as unknown as { + _performBooking: ( + event: CalendarEvent, + query: Record, + ) => Promise; + } + )._performBooking( + new CalendarEvent({ + date: new Date(2028, 5, 18, 9).valueOf(), + duration: 60, + resources: [ + new Space({ + id: 'room-1', + email: 'room@example.com', + zones: ['bld-1'], + }), + ], + }), + {}, + ); + + beforeEach(() => { + vi.spyOn(service, 'book_internal', 'get').mockReturnValue(true); + vi.mocked(TestBed.inject(SettingsService).get).mockImplementation( + (key: string) => + key === 'app.bookings.no_approval' || undefined, + ); + vi.mocked<(url: string, data: object) => Promise>( + ts_client.post, + ).mockImplementation(async (_, data) => ({ + ...data, + id: 'booking-1', + })); + }); + + it('should leave approval to the backend for a standard user', async () => { + await performBooking(service); + + expect(ts_client.post).toHaveBeenCalledWith( + expect.stringContaining('/api/staff/v1/bookings'), + expect.objectContaining({ approved: false }), + ); + }); + + it('should send approved for a support user', async () => { + setCurrentUser({ + email: 'support@test.com', + name: 'Support', + groups: ['placeos_support'], + } as any); + + await performBooking(service); + + expect(ts_client.post).toHaveBeenCalledWith( + expect.stringContaining('/api/staff/v1/bookings'), + expect.objectContaining({ approved: true }), + ); + }); + }); + it.each([0, 1, 2, 3, 4, 5, 6])( 'should submit native weekly room recurrence for weekday %s', async (weekday) => {