Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/settings/workplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
7 changes: 5 additions & 2 deletions libs/bookings/src/lib/booking-form.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
BookingRuleset,
BookingType,
currentUser,
currentUserCanApprove,
currentUserIsLoaded,
currentUserLoaded,
Desk,
Expand Down Expand Up @@ -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(
(_) => _,
),
Expand Down Expand Up @@ -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),
Expand Down
46 changes: 46 additions & 0 deletions libs/bookings/src/test/booking-form.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions libs/common/src/lib/user-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion libs/events/src/lib/event-form.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
BookingRuleset,
CalendarEvent,
currentUser,
currentUserCanApprove,
currentUserIsLoaded,
currentUserLoaded,
DEFAULT_SETTINGS,
Expand Down Expand Up @@ -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),
Expand Down
63 changes: 63 additions & 0 deletions libs/events/src/tests/event-form.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | number>,
) => Promise<CalendarEvent>;
}
)._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<unknown>>(
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) => {
Expand Down
Loading