From d658d54f028063215724ed21e5e0e964c64f1273 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 09:51:41 +1100 Subject: [PATCH 01/28] fix(explore): match desk bookings by asset id on the map API bookings reference the desk asset id while AreaManagement uses the map id, so booked desk assets showed as free. Co-Authored-By: Claude Opus 5.5 --- libs/explore/src/lib/explore-desks.service.ts | 10 +++-- .../src/tests/explore-desks.service.spec.ts | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/libs/explore/src/lib/explore-desks.service.ts b/libs/explore/src/lib/explore-desks.service.ts index 58359048602..7b777772fea 100644 --- a/libs/explore/src/lib/explore-desks.service.ts +++ b/libs/explore/src/lib/explore-desks.service.ts @@ -257,11 +257,13 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy { for (const { id, bookable, map_id } of desks) { const d_id = map_id || id; active_keys.add(d_id); - const is_used = in_use.some((i) => d_id === i); - const has_presence = presence.some((i) => d_id === i); - const has_signs = signs.some((i) => d_id === i); + // API bookings reference the desk asset id, AreaManagement uses the map id + const matches = (i: string) => i === d_id || i === id; + const is_used = in_use.some(matches); + const has_presence = presence.some(matches); + const has_signs = signs.some(matches); const is_checked_in = - checked_in.some((i) => d_id === i) || + checked_in.some(matches) || (is_used && this._settings.get(`app.desks.auto_checkin`)); const is_restricted = rulesForResource( { diff --git a/libs/explore/src/tests/explore-desks.service.spec.ts b/libs/explore/src/tests/explore-desks.service.spec.ts index 02f37cda630..a6cba590a44 100644 --- a/libs/explore/src/tests/explore-desks.service.spec.ts +++ b/libs/explore/src/tests/explore-desks.service.spec.ts @@ -152,6 +152,43 @@ describe('ExploreDesksService', () => { expect(booking_service.confirmPost).toHaveBeenCalled(); }); + it('should match API bookings to desk assets with a separate map id', () => { + const service = spectator.service as any; + const desks = [ + // a desk asset, bookings reference the asset id + new Desk({ id: 'asset-1', map_id: 'desk-1', bookable: true }), + new Desk({ id: 'asset-2', map_id: 'desk-2', bookable: true }), + // a metadata desk, bookings reference the map id + new Desk({ id: 'desk-3', bookable: true }), + new Desk({ id: 'asset-4', map_id: 'desk-4', bookable: true }), + ]; + + service._updateDeskStatuses( + desks, + ['asset-1', 'asset-2', 'desk-3'], + [], + ['asset-2'], + [], + [], + ); + + expect(service._statuses['desk-1']()).toBe('pending'); + expect(service._statuses['desk-2']()).toBe('busy'); + expect(service._statuses['desk-3']()).toBe('pending'); + expect(service._statuses['desk-4']()).toBe('free'); + }); + + it('should match AreaManagement map ids to desk assets', () => { + const service = spectator.service as any; + const desks = [ + new Desk({ id: 'asset-1', map_id: 'desk-1', bookable: true }), + ]; + + service._updateDeskStatuses(desks, [], ['desk-1'], [], [], []); + + expect(service._statuses['desk-1']()).toBe('busy'); + }); + it('should give device features the AreaManagement system id', () => { const device = { location: 'wireless', mac: 'aa:bb', x: 1, y: 1 }; const variable = vi.fn(() => ({ From 0cbb6827c25949a942b29571f89efa58b19d22be Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 09:53:11 +1100 Subject: [PATCH 02/28] fix(assets): keep custom desk fields in desk asset other_data Desk tags, homebase and third party ids (floorsense_id) are stored in other_data by the desk migration. Expose them on the desk model and merge them back on save, and keep additional security groups. Co-Authored-By: Claude Opus 5.5 --- libs/assets/src/lib/desk-assets.fn.spec.ts | 74 ++++++++++++++++++++++ libs/assets/src/lib/desk-assets.fn.ts | 72 ++++++++++++++++++++- 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/libs/assets/src/lib/desk-assets.fn.spec.ts b/libs/assets/src/lib/desk-assets.fn.spec.ts index bd9509b688a..b2f63b04302 100644 --- a/libs/assets/src/lib/desk-assets.fn.spec.ts +++ b/libs/assets/src/lib/desk-assets.fn.spec.ts @@ -119,6 +119,80 @@ describe('[Desk Assets]', () => { }); }); + it('should expose custom desk fields from other_data and save them back', async () => { + const desks = await load_module(); + const asset = new ts_client.PlaceAsset({ + id: 'asset-1', + asset_type_id: 'type-1', + zone_id: 'level-1', + identifier: 'Desk 1', + map_id: 'desk-1', + security_system_groups: ['secure-desks', 'level-6'], + other_data: { + tags: ['quiet'], + homebase: 'engineering', + floorsense_id: 'fs-1234', + } as any, + }); + + const desk = desks.deskFromAsset(asset); + expect(desk.tags).toEqual(['quiet']); + expect(desk.homebase).toBe('engineering'); + expect(desk['floorsense_id']).toBe('fs-1234'); + + // edits are merged into the existing other_data, unrelated keys survive + const edited = new (desk.constructor as any)({ + ...desk, + tags: ['quiet', 'window'], + homebase: '', + }); + const saved = desks.deskToAsset(edited, 'level-1'); + expect(saved.other_data).toEqual({ + tags: ['quiet', 'window'], + floorsense_id: 'fs-1234', + }); + // additional security groups are kept while the primary is unchanged + expect(saved.security_system_groups).toEqual([ + 'secure-desks', + 'level-6', + ]); + + const moved = new (desk.constructor as any)({ + ...desk, + security: 'other', + }); + expect( + desks.deskToAsset(moved, 'level-1').security_system_groups, + ).toEqual(['other']); + }); + + it('should store custom fields of new desks in other_data', async () => { + const desks = await load_module(); + const { Desk } = await import('@placeos/common'); + const desk = new Desk({ + id: 'desk-1', + name: 'Desk 1', + tags: ['quiet'], + floorsense_id: 'fs-1', + } as any); + + const saved = desks.deskToAsset(desk, 'level-1'); + expect(saved.id).toBeUndefined(); + expect(saved.other_data).toEqual({ + tags: ['quiet'], + floorsense_id: 'fs-1', + }); + }); + + it('should fall back to other_data map_id for the map id', async () => { + const desks = await load_module(); + const asset = new ts_client.PlaceAsset({ + id: 'asset-1', + other_data: { map_id: 'desk-legacy' }, + }); + expect(desks.deskFromAsset(asset).map_id).toBe('desk-legacy'); + }); + it('should save and delete desk assets', async () => { const desks = await load_module(); vi.mocked(ts_client.queryAssetCategories).mockReturnValue( diff --git a/libs/assets/src/lib/desk-assets.fn.ts b/libs/assets/src/lib/desk-assets.fn.ts index b36f17bd74f..a047bb83653 100644 --- a/libs/assets/src/lib/desk-assets.fn.ts +++ b/libs/assets/src/lib/desk-assets.fn.ts @@ -88,19 +88,86 @@ export async function queryDeskAssetsForZones( return flatten(results); } +/** Fields with a dedicated place on the asset or desk, everything else is kept in `other_data` */ +const DESK_ASSET_KEYS = new Set([ + 'id', + 'parent_id', + 'asset_type_id', + 'purchase_order_id', + 'zone_id', + 'identifier', + 'serial_number', + 'other_data', + 'barcode', + 'name', + 'client_ids', + 'map_id', + 'bookable', + 'accessible', + 'zones', + 'place_groups', + 'assigned_to', + 'assigned_name', + 'features', + 'images', + 'notes', + 'security_system_groups', + 'created_at', + 'updated_at', + 'zone', + 'groups', + 'security', + 'qr_code', + 'staff_name', + 'toJSON', +]); + +function isPlainValue(value: unknown) { + if (value == null) return false; + if (Array.isArray(value)) return value.every((i) => typeof i !== 'object'); + return typeof value !== 'object' && typeof value !== 'function'; +} + /** Convert a desk asset to the desk model used by booking and map views. */ export function deskFromAsset(asset: PlaceAsset, zone?: DeskZone): Desk { + const other_data = asset.other_data || {}; const security = asset.security_system_groups?.[0] || ''; return new Desk({ + // custom desk fields (tags, homebase, third party ids) live in `other_data` + ...other_data, ...asset, name: asset.identifier || asset.name, - map_id: asset.map_id || asset.id, + map_id: asset.map_id || other_data['map_id'] || asset.id, zone: new PlaceZone(zone || { id: asset.zone_id }), groups: asset.place_groups, security, }); } +/** Merge the desk's custom fields into the existing `other_data` */ +function deskOtherData(desk: Desk): Record { + const other_data: Record = { + ...(desk['other_data'] || {}), + }; + for (const key of Object.keys(desk)) { + if (DESK_ASSET_KEYS.has(key) || key.startsWith('_')) continue; + const value = desk[key]; + if (value === '' || (Array.isArray(value) && !value.length)) { + delete other_data[key]; + } else if (isPlainValue(value)) { + other_data[key] = value; + } + } + return other_data; +} + +/** Keep any additional security groups unless the primary group was changed */ +function deskSecurityGroups(desk: Desk): string[] { + if (!desk.security) return []; + const groups: string[] = desk['security_system_groups'] || []; + return groups[0] === desk.security ? groups : [desk.security]; +} + /** Convert a desk model to the asset fields used by the assets API. */ export function deskToAsset( desk: Desk, @@ -123,7 +190,8 @@ export function deskToAsset( features: desk.features, images: desk.images, notes: desk['notes'] || '', - security_system_groups: desk.security ? [desk.security] : [], + security_system_groups: deskSecurityGroups(desk), + other_data: deskOtherData(desk) as Record, }; } From 050ded5cf663c241c7554dbf944e747c8ad54f8e Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 09:55:23 +1100 Subject: [PATCH 03/28] fix(bookings): migrate favourite desks to desk asset ids Favourites saved with old metadata desk ids are rewritten to the matching desk asset (by map id) when desk assets load. Co-Authored-By: Claude Opus 5.5 --- libs/bookings/src/lib/booking-form.service.ts | 29 +++++++++++++++++-- .../src/test/booking-form.service.spec.ts | 29 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 791482b7960..e9a001e0949 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -2665,11 +2665,36 @@ export class BookingFormService extends AsyncHandler { } /** Load desk resources from the assets API for the active scope. */ - public loadDeskResources(): Promise { - return loadDeskResources( + public async loadDeskResources(): Promise { + const desks = await loadDeskResources( this._org, this._settings.get('app.use_region'), ); + this._migrateFavouriteDesks(desks); + return desks; + } + + /** + * Favourites saved before desks moved to assets reference the old desk id, + * which migrated desk assets keep as their map id. Point them at the asset. + */ + private _migrateFavouriteDesks(desks: BookingAsset[]) { + const favourites = + this._settings.get(SETTING_KEYS.FAVORITE_DESKS) || []; + if (!favourites.length) return; + const ids = new Set(desks.map((desk) => desk.id)); + const asset_ids = new Map( + desks + .filter((desk) => desk.map_id && desk.map_id !== desk.id) + .map((desk) => [desk.map_id, desk.id]), + ); + const updated = unique( + favourites.map((id) => + ids.has(id) ? id : asset_ids.get(id) || id, + ), + ); + if (updated.join() === favourites.join()) return; + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_DESKS, updated); } /** Load the resources of the given metadata `type`, e.g. `desks`. */ diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index 15712443956..14a04928b97 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -1439,6 +1439,35 @@ describe('BookingFormService', () => { expect(savedBookings().length).toBe(0); }); + it('should point favourite desks saved with old desk ids at the desk assets', () => { + const settings = spectator.inject(SettingsService); + (settings.get as Mock).mockImplementation((key: string) => + key === 'favourite_desks' + ? ['desk-1', 'asset-2', 'desk-elsewhere'] + : undefined, + ); + const save = vi + .spyOn(settings, 'saveUserSetting') + .mockImplementation(() => undefined); + + (spectator.service as any)._migrateFavouriteDesks([ + { id: 'asset-1', map_id: 'desk-1' }, + { id: 'asset-2', map_id: 'desk-2' }, + ]); + expect(save).toHaveBeenCalledWith('favourite_desks', [ + 'asset-1', + 'asset-2', + 'desk-elsewhere', + ]); + + // nothing is saved when the favourites already use asset ids + save.mockClear(); + (spectator.service as any)._migrateFavouriteDesks([ + { id: 'asset-2', map_id: 'desk-2' }, + ]); + expect(save).not.toHaveBeenCalled(); + }); + it('should not collapse overnight parking request duration to zero against parking bookable hours', () => { const get = spectator.inject(SettingsService).get as Mock; get.mockImplementation((key: string) => { From b22e0c6b5f7700f2d3c358049fc5c6087412f93e Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 09:58:57 +1100 Subject: [PATCH 04/28] fix(explore): locate desk search results by map id Desk assets have an asset id that differs from their map id, so search results failed to locate on the map and showed the asset id as the description. Co-Authored-By: Claude Opus 5.5 --- .../explore/src/lib/explore-search.service.ts | 26 ++++++++++++------- .../src/tests/explore-search.service.spec.ts | 25 ++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/libs/explore/src/lib/explore-search.service.ts b/libs/explore/src/lib/explore-search.service.ts index 0fb337fba22..c1146f09926 100644 --- a/libs/explore/src/lib/explore-search.service.ts +++ b/libs/explore/src/lib/explore-search.service.ts @@ -113,6 +113,21 @@ function typeIndex(item: SearchResult): number { return TYPES.indexOf(item.is_role ? 'contact' : item.type); } +/** Desk search result, located on the map by the desk's map id */ +export function deskSearchResult( + desk: Desk, +): SearchResult & { email?: string } { + const map_id = desk.map_id || desk.id; + return { + id: map_id, + type: 'feature', + email: desk.assigned_to, + description: map_id, + name: desk.name || map_id, + zone: desk.zone?.id || '', + }; +} + export function compareSearchResultsAscending( item_a: SearchResult, item_b: SearchResult, @@ -436,16 +451,7 @@ export class ExploreSearchService { ); } if (!this.hideItem('desks')) { - results = results.concat( - desks.map((s) => ({ - id: s.id, - type: 'feature', - email: s.assigned_to, - description: s.id, - name: s.name || s.id, - zone: s.zone?.id || '', - })), - ); + results = results.concat(desks.map(deskSearchResult)); } if (!this.hideItem('emergency_contacts')) { results = results.concat( diff --git a/libs/explore/src/tests/explore-search.service.spec.ts b/libs/explore/src/tests/explore-search.service.spec.ts index bd35951e2db..9f7eec9868b 100644 --- a/libs/explore/src/tests/explore-search.service.spec.ts +++ b/libs/explore/src/tests/explore-search.service.spec.ts @@ -2,6 +2,7 @@ import { signal } from '@angular/core'; import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; import { Building, + Desk, MapsPeopleService, Organisation, OrganisationService, @@ -9,6 +10,7 @@ import { import { compareSearchResultsAscending, + deskSearchResult, ExploreSearchService, sortGlobalSearchResults, } from '../lib/explore-search.service'; @@ -52,6 +54,29 @@ describe('ExploreSearchService', () => { expect(spectator.service).toBeTruthy(); }); + it('should locate desk assets by their map id', () => { + const result = deskSearchResult( + new Desk({ + id: 'asset-1', + map_id: 'desk-6.06.1', + name: '6.06.01', + assigned_to: 'jane@place.tech', + zone: { id: 'level-6' } as any, + }), + ); + expect(result).toEqual({ + id: 'desk-6.06.1', + type: 'feature', + email: 'jane@place.tech', + description: 'desk-6.06.1', + name: '6.06.01', + zone: 'level-6', + }); + expect(deskSearchResult(new Desk({ id: 'desk-1' })).name).toBe( + 'desk-1', + ); + }); + it('should sort search results ascending by name', () => { const results = [ { id: '2', type: 'user', name: 'Zoe', description: '' }, From 09f62fc725873f77678444babdb8db3d1cb13358 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 09:58:57 +1100 Subject: [PATCH 05/28] fix(workplace): find desks near a colleague by desk map id Colleague desk bookings reference the desk asset id, the map lookup needs the desk's map id. Co-Authored-By: Claude Opus 5.5 --- .../app/book/desk-flow/auto-assigned-desk-modal.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts b/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts index f63fa4d7a36..5c031670868 100644 --- a/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts +++ b/apps/workplace/src/app/book/desk-flow/auto-assigned-desk-modal.component.ts @@ -333,9 +333,10 @@ export class AutoAssignedDeskModalComponent const desk_ids = available_desks.map( (d) => d.map_id || d.id, ); + // bookings reference desk assets by id, the map by map id const closest_id = await findNearbyFeature( level.map_id, - nearby_desk_id, + nearby_resource.map_id || nearby_desk_id, desk_ids, ); if (closest_id) { From bdba6de56e485fe42c92e168a78bc7429b0d5a72 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:00:36 +1100 Subject: [PATCH 06/28] fix(concierge): find report levels with desk, parking and locker assets Assets were queried with the building or region as `zone_id`, which only matches the asset's level, so reports found no levels. Co-Authored-By: Claude Opus 5.5 --- .../app/reports/reports-options.component.ts | 11 ++++--- .../reports/reports-options.component.spec.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/concierge/src/app/reports/reports-options.component.ts b/apps/concierge/src/app/reports/reports-options.component.ts index fa3e5c96d8b..295978428be 100644 --- a/apps/concierge/src/app/reports/reports-options.component.ts +++ b/apps/concierge/src/app/reports/reports-options.component.ts @@ -324,10 +324,12 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { resource_type: ReportResourceType, scope_id: string, ): Promise> { + // assets are queried by level, an asset's `zone_id` is always its level + const level_ids = levels.map((level) => level.id); switch (resource_type) { case 'desks': { if (this._settings.get('app.desks.use_assets')) { - const desks = await queryDeskAssetsForZones([scope_id]); + const desks = await queryDeskAssetsForZones(level_ids); return this._zonesForResources( desks.filter((desk) => desk.bookable), ); @@ -355,13 +357,13 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { return this._zonesForResources(spaces); } case 'parking': { - const spaces = await queryParkingSpacesForZones([scope_id]); + const spaces = await queryParkingSpacesForZones(level_ids); return this._zonesForResources(spaces); } case 'lockers': { const [banks, lockers] = await Promise.all([ - queryLockerBankAssetsForZones([scope_id]), - queryLockerAssetsForZones([scope_id]), + queryLockerBankAssetsForZones(level_ids), + queryLockerAssetsForZones(level_ids), ]); const zones = new Set(); const bookable_bank_ids = new Set( @@ -384,6 +386,7 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { private _zonesForResources(resources: any[]) { const zones = new Set(); for (const resource of resources || []) { + if (resource.zone_id) zones.add(resource.zone_id); for (const zone of resource.zones || []) zones.add(zone); } return zones; diff --git a/apps/concierge/src/tests/reports/reports-options.component.spec.ts b/apps/concierge/src/tests/reports/reports-options.component.spec.ts index 6702233881f..30be7e39ff8 100644 --- a/apps/concierge/src/tests/reports/reports-options.component.spec.ts +++ b/apps/concierge/src/tests/reports/reports-options.component.spec.ts @@ -10,9 +10,12 @@ import { TranslatePipe, } from '@placeos/components'; import { DateRangeFieldComponent } from '@placeos/form-fields'; +import * as ts_client from '@placeos/ts-client'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { of } from 'rxjs'; +vi.mock('@placeos/ts-client', { spy: true }); + describe('ReportsOptionsComponent', () => { let spectator: Spectator; let navigate: any; @@ -144,6 +147,36 @@ describe('ReportsOptionsComponent', () => { expect(call[1].queryParams.zone_ids).toBeUndefined(); }); + it('should find desk asset levels when reporting on a building', async () => { + settings_map['app.desks.use_assets'] = true; + const page = (data: any[]) => + Promise.resolve({ data, total: data.length, next: () => null }); + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + page([{ id: 'cat-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + page([{ id: 'type-1', name: '_DESKS_' }]) as any, + ); + // desk assets are on levels, `zone_id` never matches the building + vi.mocked(ts_client.queryAssets).mockImplementation( + (query: any) => + page( + query.zone_id === 'lvl-1' + ? [{ id: 'asset-1', zone_id: 'lvl-1', bookable: true }] + : [], + ) as any, + ); + + const zones = await (spectator.component as any)._requestResourceZones( + [{ id: 'lvl-1' }, { id: 'lvl-2' }], + 'desks', + 'bld-report-1', + ); + + expect([...zones]).toContain('lvl-1'); + expect([...zones]).not.toContain('lvl-2'); + }); + it('should keep parking levels selectable when no spaces are returned', async () => { vi.spyOn( spectator.component as any, From f2f9145c9f070439f6b65d06e753118d604f3fdb Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:02:40 +1100 Subject: [PATCH 07/28] fix(concierge): update existing desk assets on desk import Imported rows use their own `zone_id` level and update the desk asset with the same id or map id (the original desk id) instead of creating duplicates. Co-Authored-By: Claude Opus 5.5 --- .../src/app/desks/desks-state.service.ts | 36 ++++++++++- .../tests/desks/desks-state.service.spec.ts | 62 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/apps/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index f272384ec81..a42b7079492 100644 --- a/apps/concierge/src/app/desks/desks-state.service.ts +++ b/apps/concierge/src/app/desks/desks-state.service.ts @@ -509,7 +509,8 @@ export class DesksStateService extends AsyncHandler { // written with its own desks. const groups = new Map(); for (const desk of list) { - const zone = desk.zone?.id || fallback_zone; + // imported rows carry the level as `zone_id` + const zone = desk.zone?.id || desk['zone_id'] || fallback_zone; if (!zone) { notifyError(i18n('APP.CONCIERGE.DESKS_SELECT_LEVEL')); return; @@ -519,8 +520,17 @@ export class DesksStateService extends AsyncHandler { for (const [zone, desks] of groups) { if (this._settings.get('app.desks.use_assets')) { const asset_zones = this._deskAssetZones(zone); + const existing = ( + await queryDeskAssets(zone).catch(() => []) + ).map((asset) => deskFromAsset(asset)); await Promise.all( - desks.map((desk) => saveDeskAsset(desk, zone, asset_zones)), + desks.map((desk) => + saveDeskAsset( + this._matchDeskAsset(desk, existing), + zone, + asset_zones, + ), + ), ); continue; } @@ -539,6 +549,28 @@ export class DesksStateService extends AsyncHandler { this._change.set(Date.now()); } + /** + * Desks without an asset type (imported rows, metadata era exports) update + * the desk asset with the same id or map id rather than creating a duplicate. + * Migrated desk assets keep the original desk id as their map id. + */ + private _matchDeskAsset(desk: Desk, existing: Desk[]): Desk { + if (desk['asset_type_id']) return desk; + const map_id = desk.map_id || desk.id; + const match = existing.find( + (asset) => asset.id === desk.id || asset.map_id === map_id, + ); + if (!match) return desk; + return new Desk({ + ...match, + ...desk, + id: match.id, + map_id: match.map_id, + asset_type_id: match['asset_type_id'], + other_data: match['other_data'], + } as Partial); + } + /** Remove one desk from the configured resource store. */ public async removeDesk(desk: Desk, zone_id: string) { if (this._settings.get('app.desks.use_assets')) { diff --git a/apps/concierge/src/tests/desks/desks-state.service.spec.ts b/apps/concierge/src/tests/desks/desks-state.service.spec.ts index b943c82206d..084f14769ce 100644 --- a/apps/concierge/src/tests/desks/desks-state.service.spec.ts +++ b/apps/concierge/src/tests/desks/desks-state.service.spec.ts @@ -120,6 +120,9 @@ describe('DesksStateService', () => { vi.mocked(ts_client_mod.put).mockResolvedValue({} as any); vi.mocked(ts_client_mod.del).mockResolvedValue(undefined); vi.mocked(ts_client_mod.updateMetadata).mockResolvedValue({} as never); + vi.mocked(ts_client_mod.queryAssets).mockResolvedValue({ + data: [], + } as any); vi.mocked(ts_client_mod.showMetadata).mockResolvedValue({ details: [], } as never); @@ -265,6 +268,65 @@ describe('DesksStateService', () => { expect(ts_client_mod.updateMetadata).not.toHaveBeenCalled(); }); + it('should update existing desk assets when importing desks', async () => { + settings_map['app.desks.use_assets'] = true; + vi.mocked(ts_client_mod.queryAssetCategories).mockResolvedValue({ + data: [{ id: 'desk-category', name: '_DESKS_' }], + } as any); + vi.mocked(ts_client_mod.queryAssetTypes).mockResolvedValue({ + data: [{ id: 'desk-type', name: '_DESKS_' }], + } as any); + vi.mocked(ts_client_mod.queryAssets).mockImplementation( + async (query: any) => + ({ + data: + query.zone_id === 'level-2' + ? [ + { + id: 'asset-9', + asset_type_id: 'desk-type', + zone_id: 'level-2', + identifier: 'Old Name', + map_id: 'desk-6.06.1', + other_data: { floorsense_id: 'fs-1' }, + }, + ] + : [], + }) as any, + ); + vi.mocked(ts_client_mod.updateAsset).mockResolvedValue({} as any); + vi.mocked(ts_client_mod.addAsset).mockResolvedValue({} as any); + spectator.service.setFilters({ zones: ['level-1'] }); + + // a metadata era CSV row, identified by the original desk id + await spectator.service.addDesks([ + new Desk({ + id: 'desk-6.06.1', + name: 'New Name', + zone_id: 'level-2', + } as any), + new Desk({ id: 'desk-new', name: 'Brand New' }), + ]); + + expect(ts_client_mod.updateAsset).toHaveBeenCalledTimes(1); + expect(ts_client_mod.updateAsset).toHaveBeenCalledWith( + 'asset-9', + expect.objectContaining({ + zone_id: 'level-2', + identifier: 'New Name', + map_id: 'desk-6.06.1', + other_data: expect.objectContaining({ floorsense_id: 'fs-1' }), + }), + ); + // rows without a level of their own use the selected level + expect(ts_client_mod.addAsset).toHaveBeenCalledWith( + expect.objectContaining({ + zone_id: 'level-1', + identifier: 'Brand New', + }), + ); + }); + it('should open the booking history modal for a desk booking', () => { const booking = new Booking({ id: 'booking-1' }); From 2369d4e71dd90001c646f8c79d0e34a2f622db8b Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:04:20 +1100 Subject: [PATCH 08/28] fix(concierge): check desk asset map ids for duplicates and booking names Desk assets are identified by their map id (the asset id is generated), so the duplicate check compares map ids, and bookings with the original desk id find the desk's name by map id. Co-Authored-By: Claude Opus 5.5 --- .../src/app/desks/desks-state.service.ts | 19 ++++++--- .../tests/desks/desks-state.service.spec.ts | 40 +++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index a42b7079492..3a886ccfd95 100644 --- a/apps/concierge/src/app/desks/desks-state.service.ts +++ b/apps/concierge/src/app/desks/desks-state.service.ts @@ -309,11 +309,16 @@ export class DesksStateService extends AsyncHandler { ); public readonly bookings = computed(() => { const desks = new Map(this.desks().map((desk) => [desk.id, desk])); + // bookings that predate desk assets reference the original desk id + const map_desks = new Map( + this.desks().map((desk) => [desk.map_id || desk.id, desk]), + ); const bookings = this.paged_bookings().list; for (const booking of bookings) { + const desk = + desks.get(booking.asset_id) || map_desks.get(booking.asset_id); Object.assign(booking, { - asset_name: - desks.get(booking.asset_id)?.name || booking.asset_id, + asset_name: desk?.name || booking.asset_id, }); } return [...bookings]; @@ -620,9 +625,13 @@ export class DesksStateService extends AsyncHandler { 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.`); + // Saving would store two desks with the same ID on this level. Desk + // assets are identified by their map id, the asset id is generated. + const desk_key = (item: Desk) => + use_assets ? item.map_id || item.id : item.id; + const new_key = desk_key(new_desk); + if (desk_list.filter((_) => desk_key(_) === new_key).length > 1) { + notifyError(`A desk with the ID "${new_key}" already exists.`); ref.componentInstance.loading.set(false); ref.close(); return; diff --git a/apps/concierge/src/tests/desks/desks-state.service.spec.ts b/apps/concierge/src/tests/desks/desks-state.service.spec.ts index 084f14769ce..94ff3b37637 100644 --- a/apps/concierge/src/tests/desks/desks-state.service.spec.ts +++ b/apps/concierge/src/tests/desks/desks-state.service.spec.ts @@ -468,6 +468,35 @@ describe('DesksStateService', () => { ); }); + it('should not add a desk asset with a map ID already used on the level', async () => { + settings_map['app.desks.use_assets'] = true; + vi.spyOn(spectator.service as any, '_levelDeskList').mockResolvedValue([ + new Desk({ + id: 'asset-1', + map_id: 'desk-dup', + asset_type_id: 'desk-type', + } as any), + ]); + (spectator.inject(MatDialog).open as any).mockReturnValue({ + afterClosed: () => + of({ + reason: 'done', + metadata: { id: 'desk-dup', name: 'New', zone_id: 'lvl-1' }, + }), + componentInstance: { event: NEVER, loading: { set: vi.fn() } }, + close: vi.fn(), + }); + + await spectator.service.editDesk(); + + expect(ts_client_mod.addAsset).not.toHaveBeenCalled(); + expect(notify_open).toHaveBeenCalledWith( + 'A desk with the ID "desk-dup" already exists.', + expect.anything(), + expect.objectContaining({ panelClass: ['error'] }), + ); + }); + it('should default a new desk to the first level when none is selected', async () => { const dialog_ref = { afterClosed: () => of(undefined), @@ -614,6 +643,17 @@ describe('DesksStateService', () => { 'Desk One', 'desk-2', ]); + + // desk assets keep the original desk id as their map id + desks.set([ + new Desk({ id: 'asset-1', map_id: 'desk-1', name: 'Desk One' }), + new Desk({ id: 'asset-2', map_id: 'desk-2', name: 'Desk Two' }), + ]); + + expect(spectator.service.bookings().map((_) => _.asset_name)).toEqual([ + 'Desk One', + 'Desk Two', + ]); }); it('should keep rejected status when a fast reload returns the old approved state', async () => { From 5f78d8a673a31692878307fab26dcbc92de07665 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:05:09 +1100 Subject: [PATCH 09/28] fix(concierge): protect desk asset map ids in the map manager Co-Authored-By: Claude Opus 5.5 --- .../app/map-manager/map-management.service.ts | 6 +++- .../src/app/map-manager/protected-ids.ts | 16 +++++++++ .../map-management.service.spec.ts | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/concierge/src/app/map-manager/map-management.service.ts b/apps/concierge/src/app/map-manager/map-management.service.ts index 53fca9f0f6d..50c7dc0fe49 100644 --- a/apps/concierge/src/app/map-manager/map-management.service.ts +++ b/apps/concierge/src/app/map-manager/map-management.service.ts @@ -164,7 +164,11 @@ export class MapManagementService { public protectedIdSources( level: BuildingLevel, ): Record { - return conciergeProtectedIdSources(level, this._org.organisation.id); + return conciergeProtectedIdSources( + level, + this._org.organisation.id, + !!this._settings.get('app.desks.use_assets'), + ); } /** diff --git a/apps/concierge/src/app/map-manager/protected-ids.ts b/apps/concierge/src/app/map-manager/protected-ids.ts index 23d303f6aea..afa0dd5457e 100644 --- a/apps/concierge/src/app/map-manager/protected-ids.ts +++ b/apps/concierge/src/app/map-manager/protected-ids.ts @@ -1,4 +1,6 @@ import { + deskFromAsset, + queryDeskAssets, queryLockerAssetsForZones, queryLockerBankAssetsForZones, queryParkingSpacesForZones, @@ -34,9 +36,23 @@ function assetMapId(asset: PlaceAsset): string { export function conciergeProtectedIdSources( level: BuildingLevel, organisation_id: string, + use_desk_assets = false, ): Record { return { desks: async () => { + if (use_desk_assets) { + const assets = await queryDeskAssets(level.id); + return assets + .map((asset) => deskFromAsset(asset)) + .map( + (desk): ProtectedId => ({ + id: desk.map_id, + kind: 'desk', + label: desk.name || desk.map_id, + }), + ) + .filter((entry) => !!entry.id); + } const metadata = await showMetadata(level.id, 'desks'); const list = Array.isArray(metadata.details) ? (metadata.details as DeskRecord[]) diff --git a/apps/concierge/src/tests/map-manager/map-management.service.spec.ts b/apps/concierge/src/tests/map-manager/map-management.service.spec.ts index e98baee9bf0..cd3b4294e12 100644 --- a/apps/concierge/src/tests/map-manager/map-management.service.spec.ts +++ b/apps/concierge/src/tests/map-manager/map-management.service.spec.ts @@ -198,6 +198,39 @@ describe('MapManagementService', () => { ); }); + it('protects desk asset map ids when desks use assets', async () => { + settings.get.mockImplementation((key: string) => + key === 'app.desks.use_assets' ? true : undefined, + ); + const page = (data: any[]) => + Promise.resolve({ data, total: data.length, next: () => null }); + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + page([{ id: 'cat-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + page([{ id: 'type-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(ts_client.queryAssets).mockReturnValue( + page([ + { + id: 'asset-1', + zone_id: 'l1', + identifier: 'Desk 1', + map_id: 'desk-1', + }, + ]) as any, + ); + + const sources = spectator.service.protectedIdSources({ + id: 'l1', + } as any); + + await expect(sources.desks()).resolves.toEqual([ + { id: 'desk-1', kind: 'desk', label: 'Desk 1' }, + ]); + expect(ts_client.showMetadata).not.toHaveBeenCalledWith('l1', 'desks'); + }); + it('stores an AI snapshot as a file and answers with the upload id', async () => { const blob = new Blob(['png'], { type: 'image/png' }); await expect( From 4c4e1868cf5b98e4f759d9a1dda98a8f388ded44 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:05:58 +1100 Subject: [PATCH 10/28] fix(common): match booking rule resource ids against desk map ids Rules saved before desks moved to assets list the original desk ids, which desk assets keep as their map id. Co-Authored-By: Claude Opus 5.5 --- libs/common/src/lib/booking-rules.ts | 7 +++- libs/common/src/tests/booking-rules.spec.ts | 39 ++++++++++++------- libs/explore/src/lib/explore-desks.service.ts | 2 + 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/libs/common/src/lib/booking-rules.ts b/libs/common/src/lib/booking-rules.ts index 1cac96a3248..fa3c7b08d0e 100644 --- a/libs/common/src/lib/booking-rules.ts +++ b/libs/common/src/lib/booking-rules.ts @@ -23,6 +23,8 @@ export interface BookingRules { export interface BookableResource extends Record { id: string; + /** Map element id, desk assets keep the original desk id here */ + map_id?: string; zones?: string[]; zone?: { id: string; name: string }; } @@ -181,9 +183,12 @@ export function checkRulesMatch( matches += 1; if (conditions.max_length && conditions.max_length >= duration) matches += 1; + // rules saved before desks moved to assets reference the original desk id if ( conditions.resource_ids && - conditions.resource_ids.includes(resource.id) + (conditions.resource_ids.includes(resource.id) || + (resource.map_id && + conditions.resource_ids.includes(resource.map_id))) ) matches += 1; if ( diff --git a/libs/common/src/tests/booking-rules.spec.ts b/libs/common/src/tests/booking-rules.spec.ts index 744b3f01ecc..afb6edbf8e5 100644 --- a/libs/common/src/tests/booking-rules.spec.ts +++ b/libs/common/src/tests/booking-rules.spec.ts @@ -52,9 +52,9 @@ describe('addToDate', () => { describe('checkRulesMatch', () => { it('should match when there are no conditions', () => { - expect( - checkRulesMatch(details(), { conditions: null } as any), - ).toBe(true); + expect(checkRulesMatch(details(), { conditions: null } as any)).toBe( + true, + ); }); it('should require all conditions to match', () => { @@ -68,9 +68,7 @@ describe('checkRulesMatch', () => { }; expect(checkRulesMatch(details(), ruleset)).toBe(true); // resource matches but duration is below min_length - expect(checkRulesMatch(details({ duration: 15 }), ruleset)).toBe( - false, - ); + expect(checkRulesMatch(details({ duration: 15 }), ruleset)).toBe(false); }); it('should match resource ids', () => { @@ -82,6 +80,21 @@ describe('checkRulesMatch', () => { expect(checkRulesMatch(details(), ruleset)).toBe(false); }); + it('should match desk assets by the original desk id in their map id', () => { + const ruleset: BookingRuleset = { + zone: '*', + rules: {}, + conditions: { resource_ids: ['desk-6.06.1'] }, + }; + const asset = (map_id?: string) => + details({ + resource: { id: 'asset-1', map_id, zones: ['zone-1'] }, + }); + expect(checkRulesMatch(asset('desk-6.06.1'), ruleset)).toBe(true); + expect(checkRulesMatch(asset('desk-other'), ruleset)).toBe(false); + expect(checkRulesMatch(asset(), ruleset)).toBe(false); + }); + it('should match host groups', () => { const ruleset: BookingRuleset = { zone: '*', @@ -117,9 +130,7 @@ describe('checkRulesMatch', () => { conditions: { min_length: 30, max_length: 120 }, }; expect(checkRulesMatch(details({ duration: 60 }), ruleset)).toBe(true); - expect(checkRulesMatch(details({ duration: 15 }), ruleset)).toBe( - false, - ); + expect(checkRulesMatch(details({ duration: 15 }), ruleset)).toBe(false); expect(checkRulesMatch(details({ duration: 240 }), ruleset)).toBe( false, ); @@ -148,9 +159,9 @@ describe('checkRulesMatch', () => { conditions: { is_period: [now - 1000, now + 1000] }, }; expect(checkRulesMatch(details({ date: now }), ruleset)).toBe(true); - expect( - checkRulesMatch(details({ date: now + 5000 }), ruleset), - ).toBe(false); + expect(checkRulesMatch(details({ date: now + 5000 }), ruleset)).toBe( + false, + ); }); it('should match locations against the resource name', () => { @@ -185,9 +196,7 @@ describe('rulesForResource', () => { it('should match wildcard zone rulesets', () => { expect( - rulesForResource(details(), [ - { ...hidden_ruleset, zone: '*' }, - ]), + rulesForResource(details(), [{ ...hidden_ruleset, zone: '*' }]), ).toEqual(hidden_ruleset.rules); }); diff --git a/libs/explore/src/lib/explore-desks.service.ts b/libs/explore/src/lib/explore-desks.service.ts index 7b777772fea..2462871d2c9 100644 --- a/libs/explore/src/lib/explore-desks.service.ts +++ b/libs/explore/src/lib/explore-desks.service.ts @@ -272,6 +272,7 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy { host: currentUser(), resource: { id, + map_id, zones: [level?.parent_id, level?.id], }, }, @@ -593,6 +594,7 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy { host: currentUser(), resource: { id: asset_id, + map_id: desk.map_id, zones: [desk.zone?.parent_id, desk.zone?.id], }, }, From 58b67dba69425378b2079fe382cd8b9944434ca3 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:07:03 +1100 Subject: [PATCH 11/28] fix(assets): load every page of desk assets and retry the desk type lookup Desk queries stopped at 500 assets per level, and a failed desk type lookup was cached for the session, leaving desk lists empty until a reload. Co-Authored-By: Claude Opus 5.5 --- libs/assets/src/lib/assets.fn.ts | 3 +- libs/assets/src/lib/desk-assets.fn.spec.ts | 38 ++++++++++++++++++++++ libs/assets/src/lib/desk-assets.fn.ts | 21 ++++++++---- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/libs/assets/src/lib/assets.fn.ts b/libs/assets/src/lib/assets.fn.ts index 01a819d5306..9fe54e3a3a0 100644 --- a/libs/assets/src/lib/assets.fn.ts +++ b/libs/assets/src/lib/assets.fn.ts @@ -127,7 +127,8 @@ export function saveAssetCategory(category: Partial) { const _GROUPS_CACHE = new Map(); const REMOVE_QUERY_KEYS = ['period_start', 'period_end', 'type', 'rejected']; -async function queryAllAssetPages(query: any = {}) { +/** Query assets, following pagination until every page is loaded */ +export async function queryAllAssetPages(query: any = {}) { let response = await queryAssetsAPI({ ...query, limit: query.limit || 500, diff --git a/libs/assets/src/lib/desk-assets.fn.spec.ts b/libs/assets/src/lib/desk-assets.fn.spec.ts index b2f63b04302..4b2a683d194 100644 --- a/libs/assets/src/lib/desk-assets.fn.spec.ts +++ b/libs/assets/src/lib/desk-assets.fn.spec.ts @@ -86,6 +86,44 @@ describe('[Desk Assets]', () => { ]); }); + it('should retry resolving the desk type after a failure', async () => { + const desks = await load_module(); + vi.mocked(ts_client.queryAssetCategories) + .mockReturnValueOnce(Promise.reject('offline') as any) + .mockReturnValue(response([{ id: 'cat-1', name: '_DESKS_' }])); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + response([{ id: 'type-1', name: '_DESKS_' }]), + ); + + await expect(desks.resolveDeskTypeId()).rejects.toBe('offline'); + expect(await desks.resolveDeskTypeId()).toBe('type-1'); + }); + + it('should load every page of desk assets on a level', async () => { + const desks = await load_module(); + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + response([{ id: 'cat-1', name: '_DESKS_' }]), + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + response([{ id: 'type-1', name: '_DESKS_' }]), + ); + vi.mocked(ts_client.queryAssets).mockReturnValue( + Promise.resolve({ + data: [{ id: 'asset-1' }], + total: 2, + next: () => + Promise.resolve({ + data: [{ id: 'asset-2' }], + total: 2, + next: () => null, + }), + }) as any, + ); + + const assets = await desks.queryDeskAssets('level-1'); + expect(assets.map((asset) => asset.id)).toEqual(['asset-1', 'asset-2']); + }); + it('should map asset fields to and from a desk', async () => { const desks = await load_module(); const asset = new ts_client.PlaceAsset({ diff --git a/libs/assets/src/lib/desk-assets.fn.ts b/libs/assets/src/lib/desk-assets.fn.ts index a047bb83653..5ed126488b7 100644 --- a/libs/assets/src/lib/desk-assets.fn.ts +++ b/libs/assets/src/lib/desk-assets.fn.ts @@ -3,12 +3,12 @@ import { PlaceAsset, PlaceZone, queryAssetCategories, - queryAssets, queryAssetTypes, removeAsset, } from '@placeos/ts-client'; import { findOldestByName, + queryAllAssetPages, saveAsset, saveAssetCategory, saveAssetType, @@ -57,10 +57,17 @@ async function bootstrap_desk_type(): Promise { export function resolveDeskTypeId(): Promise { if (_desk_type_id) return Promise.resolve(_desk_type_id); if (!_desk_type_id_promise) { - _desk_type_id_promise = bootstrap_desk_type().then((id) => { - _desk_type_id = id; - return id; - }); + _desk_type_id_promise = bootstrap_desk_type().then( + (id) => { + _desk_type_id = id; + return id; + }, + (error) => { + // allow a later call to retry, e.g. after a network error + _desk_type_id_promise = null; + throw error; + }, + ); } return _desk_type_id_promise; } @@ -68,7 +75,7 @@ export function resolveDeskTypeId(): Promise { /** Query desk assets for one level. */ export async function queryDeskAssets(zone_id: string): Promise { const type_id = await resolveDeskTypeId(); - const response = await queryAssets({ zone_id, type_id, limit: 500 }); + const response = await queryAllAssetPages({ zone_id, type_id }); return response.data; } @@ -80,7 +87,7 @@ export async function queryDeskAssetsForZones( const type_id = await resolveDeskTypeId(); const results = await Promise.all( zone_ids.map((zone_id) => - queryAssets({ zone_id, type_id, limit: 500 }).then( + queryAllAssetPages({ zone_id, type_id }).then( (response) => response.data, ), ), From 25156938ee58850f469fceb20c8d06c0188762fc Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:11:01 +1100 Subject: [PATCH 12/28] fix(workplace): resolve desk links to desk assets Links with the original desk id match the desk asset's map id, and in assets mode the building search reads desk assets instead of the stale desks metadata. Co-Authored-By: Claude Opus 5.5 --- .../book/desk-flow-new/desk-flow.component.ts | 39 +++++++++++++++- .../book/desk-flow-new.component.spec.ts | 46 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts index 7760e823f9c..499e83ce6d0 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { deskFromAsset, queryDeskAssetsForZones } from '@placeos/assets'; import { BookingAsset, BookingFlowView, @@ -26,6 +27,7 @@ import { notifyError, notifySuccess, OrganisationService, + SettingsService, User, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; @@ -40,6 +42,14 @@ import { import { DeskFlowSelectComponent } from './desk-flow-select.component'; import { NewDeskFlowSuccessComponent } from './desk-flow-success.component'; +/** Desk links use the desk asset id, or the original desk id kept as the map id */ +function deskMatchesLink( + desk: { id: string; map_id?: string }, + asset_id: string, +) { + return desk.id === asset_id || desk.map_id === asset_id; +} + @Component({ selector: 'desk-flow-new', template: ` @@ -152,6 +162,7 @@ import { NewDeskFlowSuccessComponent } from './desk-flow-success.component'; export class DeskFlowNewComponent extends AsyncHandler implements OnInit { private _booking_form = inject(BookingFormService); private _org = inject(OrganisationService); + private _settings = inject(SettingsService); private _router = inject(Router); private _route = inject(ActivatedRoute); private _space_pipe: SpacePipe = new SpacePipe(this._org); @@ -324,7 +335,9 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { const deadline = Date.now() + wait_ms; do { const resources = await this._booking_form.listResources(); - const resource = resources.find((item) => item.id === asset_id); + const resource = resources.find((item) => + deskMatchesLink(item, asset_id), + ); if (resource) return resource; if (Date.now() >= deadline) break; await new Promise((resolve) => setTimeout(resolve, 50)); @@ -335,6 +348,9 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { private async _findDeskResourceFromBuildings( asset_id: string, ): Promise { + if (this._settings.get('app.desks.use_assets')) { + return this._findDeskAssetFromBuildings(asset_id); + } for (const building of this._org.buildings || []) { const data = await listChildMetadata(building.id, { name: 'desks', @@ -357,6 +373,27 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { return null; } + private async _findDeskAssetFromBuildings( + asset_id: string, + ): Promise { + for (const building of this._org.buildings || []) { + const levels = this._org.levelsForBuilding(building); + const assets = await queryDeskAssetsForZones( + levels.map((level) => level.id), + ).catch(() => []); + const desk = assets + .map((asset) => + deskFromAsset( + asset, + levels.find((level) => level.id === asset.zone_id), + ), + ) + .find((item) => deskMatchesLink(item, asset_id)); + if (desk) return desk as unknown as BookingAsset; + } + return null; + } + private async _initNearbyDeskBooking(space_id: string, event_date: number) { const space = await this._space_pipe.transform(space_id); const level = this._org.levelWithID(space?.zones); diff --git a/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts index d24f0c5366e..e678c999a46 100644 --- a/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts +++ b/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts @@ -12,6 +12,7 @@ import { Booking, EMPTY_USER, OrganisationService, + SettingsService, setCurrentUser, StaffUser, User, @@ -20,6 +21,9 @@ import { MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { listChildMetadata, + queryAssetCategories, + queryAssets, + queryAssetTypes, querySystemsWithEmails, showSystem, authority, @@ -303,6 +307,48 @@ describe('DeskFlowNewComponent', () => { expect(model().asset_id).toBe('desk-456'); }); + it('should resolve desk links with the original desk id to the desk asset', async () => { + resource_list = []; + const page = (data: any[]) => + Promise.resolve({ data, total: data.length, next: () => null }); + vi.mocked(queryAssetCategories).mockReturnValue( + page([{ id: 'cat-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(queryAssetTypes).mockReturnValue( + page([{ id: 'type-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(queryAssets).mockImplementation( + (query: any) => + page( + query.zone_id === 'level-2' + ? [ + { + id: 'asset-456', + asset_type_id: 'type-1', + zone_id: 'level-2', + identifier: 'Desk 456', + map_id: 'desk-456', + }, + ] + : [], + ) as any, + ); + query_params = { asset_id: 'desk-456' }; + const settings = TestBed.inject(SettingsService); + const get = settings.get.bind(settings); + vi.spyOn(settings, 'get').mockImplementation(((key: string) => + key === 'app.desks.use_assets' ? true : get(key)) as any); + spectator = createComponent(); + + await spectator.component.ngOnInit(); + await waitFor(() => model().asset_id === 'asset-456', 2000); + + expect(model().asset_id).toBe('asset-456'); + expect(model().resources).toEqual([ + expect.objectContaining({ id: 'asset-456', name: 'Desk 456' }), + ]); + }); + it('should select a nearby desk by resource id when no map id is available', async () => { const desk_without_map_id = { id: 'desk-nearby', From 7fc904511fcad10f916f396c2420ae7e34c64c13 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:12:42 +1100 Subject: [PATCH 13/28] fix(workplace): check in to desk assets from codes with the original desk id Co-Authored-By: Claude Opus 5.5 --- .../src/app/book/code-flow.component.ts | 28 +++++++++++- .../tests/book/code-flow.component.spec.ts | 45 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/apps/workplace/src/app/book/code-flow.component.ts b/apps/workplace/src/app/book/code-flow.component.ts index 93f61e30d66..92a6299e57c 100644 --- a/apps/workplace/src/app/book/code-flow.component.ts +++ b/apps/workplace/src/app/book/code-flow.component.ts @@ -10,6 +10,7 @@ import { viewChild, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { queryDeskAssetsForZones } from '@placeos/assets'; import { BookingFormService, queryBookings, @@ -23,6 +24,7 @@ import { currentUser, i18n, notifyError, + SettingsService, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { @@ -211,6 +213,7 @@ export class BookCodeFlowComponent private readonly _event_form = inject(EventFormService); private readonly _booking_form = inject(BookingFormService); private readonly _org = inject(OrganisationService); + private readonly _settings = inject(SettingsService); public readonly menu = output(); public readonly is_scanning = signal(false); @@ -297,7 +300,11 @@ export class BookCodeFlowComponent type, email: currentUser().email, }).catch((_) => [] as Booking[]); - const item = current_bookings.find((_) => _.asset_id === asset_id); + let item = current_bookings.find((_) => _.asset_id === asset_id); + if (!item && type === 'desk') { + asset_id = await this._deskAssetId(asset_id); + item = current_bookings.find((_) => _.asset_id === asset_id); + } if (item) { await setBookingCheckedIn(item, true).catch((_) => { notifyError(i18n('BOOKINGS.CHECK_IN_ERROR')); @@ -335,6 +342,25 @@ export class BookCodeFlowComponent this.loading.set(false); } + /** + * Codes printed before desks moved to assets hold the original desk id, + * which desk assets keep as their map id. Bookings reference the asset id. + */ + private async _deskAssetId(code: string): Promise { + if (!this._settings.get('app.desks.use_assets')) return code; + const levels = this._org.levelsForBuilding(); + const assets = await queryDeskAssetsForZones( + levels.map((level) => level.id), + ).catch(() => []); + const asset = assets.find( + (item) => + item.id === code || + item.map_id === code || + item.other_data?.['map_id'] === code, + ); + return asset?.id || code; + } + private async _checkinEvent(space_id: string, email?: string) { if (!email) email = currentUser().email; this.loading.set(true); diff --git a/apps/workplace/src/tests/book/code-flow.component.spec.ts b/apps/workplace/src/tests/book/code-flow.component.spec.ts index 62f15231834..edac9a4976b 100644 --- a/apps/workplace/src/tests/book/code-flow.component.spec.ts +++ b/apps/workplace/src/tests/book/code-flow.component.spec.ts @@ -5,7 +5,12 @@ import { SpectatorRouting, } from '@ngneat/spectator/vitest'; import { BookingFormService } from '@placeos/bookings'; -import { setCurrentUser, SettingsService, StaffUser } from '@placeos/common'; +import { + OrganisationService, + setCurrentUser, + SettingsService, + StaffUser, +} from '@placeos/common'; import { EventFormService } from '@placeos/events'; import { MockProvider } from 'ng-mocks'; @@ -54,4 +59,42 @@ describe('BookCodeFlowComponent', () => { { queryParams: { type: 'wrong_resource', asset_id: 'desk-1' } }, ); }); + + it('should check in to a desk asset booking from a code with the original desk id', async () => { + setCurrentUser(new StaffUser({ id: 'me', email: 'me@test.com' })); + vi.mocked(spectator.inject(SettingsService).get).mockImplementation( + (key: string) => + key === 'app.desks.use_assets' ? true : undefined, + ); + vi.spyOn( + spectator.inject(OrganisationService), + 'levelsForBuilding', + ).mockReturnValue([{ id: 'level-1' }] as any); + const page = (data: any[]) => + Promise.resolve({ data, total: data.length, next: () => null }); + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + page([{ id: 'cat-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + page([{ id: 'type-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(ts_client.queryAssets).mockReturnValue( + page([ + { id: 'asset-1', zone_id: 'level-1', map_id: 'desk-1' }, + ]) as any, + ); + vi.mocked(ts_client.get).mockImplementation( + () => Promise.resolve([{ id: 'b-1', asset_id: 'asset-1' }]) as any, + ); + vi.mocked(ts_client.post).mockResolvedValue({} as any); + const router = spectator.inject(Router); + + await (spectator.component as any)._checkinBooking('desk-1'); + + expect(router.navigate).toHaveBeenCalledWith([ + '/book', + 'code', + 'success', + ]); + }); }); From 8b2880ff1ed60963a22728db3ab8ed4292a9173b Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:13:51 +1100 Subject: [PATCH 14/28] fix(concierge): collect new desk asset details before saving New desks are saved straight away, so in assets mode open the desk modal rather than creating a nameless desk asset. Co-Authored-By: Claude Opus 5.5 --- .../src/app/desks/desks-topbar.component.ts | 7 +++++++ .../tests/desks/desks-topbar.component.spec.ts | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/concierge/src/app/desks/desks-topbar.component.ts b/apps/concierge/src/app/desks/desks-topbar.component.ts index aece91560b5..2065ea15060 100644 --- a/apps/concierge/src/app/desks/desks-topbar.component.ts +++ b/apps/concierge/src/app/desks/desks-topbar.component.ts @@ -25,6 +25,7 @@ import { notifyInfo, OrganisationService, randomInt, + SettingsService, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { DateOptionsComponent } from '@placeos/form-fields'; @@ -177,6 +178,7 @@ export class DesksTopbarComponent extends AsyncHandler implements OnInit { private _route = inject(ActivatedRoute); private _router = inject(Router); private _dialog = inject(MatDialog); + private _settings = inject(SettingsService); /** List of levels for the active building */ public readonly all_levels = this._org.active_levels; @@ -265,6 +267,11 @@ export class DesksTopbarComponent extends AsyncHandler implements OnInit { } public newDesk() { + // desk assets are saved straight away, collect the details first + if (this._settings.get('app.desks.use_assets')) { + this._desks.editDesk(); + return; + } const desk = new Desk({ id: `desk-${randomInt(999_999)}` }); desk.staff_name = `[NEW_DESK]`; this._desks.addDesks([desk]); diff --git a/apps/concierge/src/tests/desks/desks-topbar.component.spec.ts b/apps/concierge/src/tests/desks/desks-topbar.component.spec.ts index 92de94fa8fb..43e00b768a2 100644 --- a/apps/concierge/src/tests/desks/desks-topbar.component.spec.ts +++ b/apps/concierge/src/tests/desks/desks-topbar.component.spec.ts @@ -7,7 +7,7 @@ import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; +import { OrganisationService, SettingsService } from '@placeos/common'; import { MockComponent, MockProvider } from 'ng-mocks'; import { of, timer } from 'rxjs'; @@ -37,7 +37,10 @@ describe('DesksTopbarComponent', () => { setFilters: vi.fn(), approveDesk: vi.fn(), rejectDesk: vi.fn(), + addDesks: vi.fn(), + editDesk: vi.fn(), }), + MockProvider(SettingsService, { get: vi.fn() }), MockProvider(OrganisationService, { active_levels: signal([]), initialised: signal(true), @@ -63,6 +66,19 @@ describe('DesksTopbarComponent', () => { spectator = createComponent(); }); + it('should collect the details of a new desk asset before saving it', () => { + const desks = spectator.inject(DesksStateService); + vi.mocked(spectator.inject(SettingsService).get).mockImplementation( + (key: string) => + key === 'app.desks.use_assets' ? true : undefined, + ); + + spectator.component.newDesk(); + + expect(desks.editDesk).toHaveBeenCalledWith(); + expect(desks.addDesks).not.toHaveBeenCalled(); + }); + it('should handle zone_ids query param', async () => { vi.spyOn(spectator.component, 'updateZones'); expect(spectator.component.updateZones).not.toHaveBeenCalled(); From c493063c6cd6cddb45546384554d3841d5e718df Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:18:54 +1100 Subject: [PATCH 15/28] test(assets): import Desk statically in desk asset spec Co-Authored-By: Claude Opus 5.5 --- libs/assets/src/lib/desk-assets.fn.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/assets/src/lib/desk-assets.fn.spec.ts b/libs/assets/src/lib/desk-assets.fn.spec.ts index 4b2a683d194..332db4515bc 100644 --- a/libs/assets/src/lib/desk-assets.fn.spec.ts +++ b/libs/assets/src/lib/desk-assets.fn.spec.ts @@ -1,3 +1,4 @@ +import { Desk } from '@placeos/common'; import * as ts_client from '@placeos/ts-client'; vi.mock('@placeos/ts-client', { spy: true }); @@ -206,7 +207,6 @@ describe('[Desk Assets]', () => { it('should store custom fields of new desks in other_data', async () => { const desks = await load_module(); - const { Desk } = await import('@placeos/common'); const desk = new Desk({ id: 'desk-1', name: 'Desk 1', From 00539e40c8a1c5b5488ed1427985d8f2cc8dc4f1 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:41:47 +1100 Subject: [PATCH 16/28] fix(concierge): save the full zone path on parking and locker assets Parking spaces, locker banks and lockers now store the org, region, building and level in `zones` like desk assets (shared `assetZones`), so they can be queried by building. Parking updates refresh the zones without moving the space. Co-Authored-By: Claude Opus 5.5 --- .../src/app/desks/desks-state.service.ts | 14 +------ .../src/app/lockers/locker-state.service.ts | 23 +++++++++--- .../src/app/parking/parking-state.service.ts | 23 +++++------- .../lockers/locker-state.service.spec.ts | 7 ++++ .../parking/parking-state.service.spec.ts | 10 ++++- libs/common/src/index.ts | 1 + libs/common/src/lib/org/asset-zones.ts | 25 +++++++++++++ libs/common/src/tests/asset-zones.spec.ts | 37 +++++++++++++++++++ 8 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 libs/common/src/lib/org/asset-zones.ts create mode 100644 libs/common/src/tests/asset-zones.spec.ts diff --git a/apps/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index 3a886ccfd95..fdfc978dcfb 100644 --- a/apps/concierge/src/app/desks/desks-state.service.ts +++ b/apps/concierge/src/app/desks/desks-state.service.ts @@ -29,6 +29,7 @@ import { updateBooking, } from '@placeos/bookings'; import { + assetZones, AsyncHandler, Booking, BuildingLevel, @@ -1152,18 +1153,7 @@ export class DesksStateService extends AsyncHandler { /** Build the complete zone path required for asset scope queries. */ private _deskAssetZones(level_id: string) { - const level = this._org.levels?.find((item) => item.id === level_id); - const building = - this._org.buildings?.find((item) => item.id === level?.parent_id) || - (this._org.building?.id === level?.parent_id - ? this._org.building - : undefined); - return unique([ - this._org.organisation?.id, - building?.parent_id || this._org.region?.id, - building?.id || level?.parent_id, - level_id, - ]).filter((id) => !!id); + return assetZones(this._org, level_id); } private _createAssignedBooking(desk: Desk, zone?: string) { diff --git a/apps/concierge/src/app/lockers/locker-state.service.ts b/apps/concierge/src/app/lockers/locker-state.service.ts index d18cc1851cb..64fbaa02b94 100644 --- a/apps/concierge/src/app/lockers/locker-state.service.ts +++ b/apps/concierge/src/app/lockers/locker-state.service.ts @@ -33,6 +33,7 @@ import { updateBooking, } from '@placeos/bookings'; import { + assetZones, AsyncHandler, Booking, getTimezoneDifferenceInHours, @@ -79,6 +80,7 @@ const removeToken = (l: string, t: string) => l.replace(t, ''); function lockerBankToAsset( bank: Partial, zone_id: string, + zones: string[], ): Partial { return { ...(bank.id ? { id: bank.id } : {}), @@ -86,7 +88,7 @@ function lockerBankToAsset( map_id: bank.map_id || '', notes: (bank as any).notes || '', zone_id, - zones: bank.zones || [zone_id], + zones, tags: bank.tags || [], other_data: { name: bank.name || '', @@ -101,13 +103,14 @@ function lockerBankToAsset( function lockerToAsset( locker: Partial, zone_id: string, + zones: string[], ): Partial { return { ...(locker.id ? { id: locker.id } : {}), identifier: locker.name || '', map_id: locker.map_id || '', zone_id, - zones: locker.bank?.zones || [], + zones, features: locker.features || [], bookable: locker.bookable !== false, parent_id: locker.bank_id || '', @@ -620,7 +623,11 @@ export class LockerStateService extends AsyncHandler { const zone_id = state.metadata.level_id || this._org.building.id; const new_bank = { ...state.metadata, id: bank.id }; const saved = await saveLockerBankAsset( - lockerBankToAsset(new_bank, zone_id), + lockerBankToAsset( + new_bank, + zone_id, + assetZones(this._org, zone_id), + ), ).catch((e) => { notifyError(`Failed to save locker bank. ${errorText(e)}`); throw e; @@ -639,7 +646,9 @@ export class LockerStateService extends AsyncHandler { await saveFromModal(ref, async (state) => { let saved: PlaceAsset; try { - const zone_id = bank.zones?.[0] || this._org.building.id; + // banks list every zone they are in, `level_id` is the bank's level + const zone_id = + bank.level_id || bank.zones?.[0] || this._org.building.id; const new_locker = { ...state.metadata, bank_id: bank.id, @@ -649,7 +658,11 @@ export class LockerStateService extends AsyncHandler { // Save the locker before clearing the old assignee's booking, so a // failed save does not remove the booking. saved = await saveLockerAsset( - lockerToAsset(new_locker, zone_id), + lockerToAsset( + new_locker, + zone_id, + assetZones(this._org, zone_id), + ), ); if ( locker.assigned_to && diff --git a/apps/concierge/src/app/parking/parking-state.service.ts b/apps/concierge/src/app/parking/parking-state.service.ts index b44a1b74949..26fb091602f 100644 --- a/apps/concierge/src/app/parking/parking-state.service.ts +++ b/apps/concierge/src/app/parking/parking-state.service.ts @@ -41,6 +41,7 @@ import { updateBookingInstance, } from '@placeos/bookings'; import { + assetZones, AsyncHandler, Booking, csvToJson, @@ -498,7 +499,11 @@ export class ParkingStateService extends AsyncHandler { if (email && !users.has(email)) users.set(email, user); } let list = reset ? [] : this._bookings_state().list; - for (let page_count = 1; page_count <= MAX_BOOKING_PAGES; page_count++) { + for ( + let page_count = 1; + page_count <= MAX_BOOKING_PAGES; + page_count++ + ) { let failed = false; const resp = await Promise.resolve(fetch()).catch(() => { failed = true; @@ -731,12 +736,7 @@ export class ParkingStateService extends AsyncHandler { return; } // New spaces get the same zones as spaces created in the modal. - const zones = unique([ - this._org.organisation.id, - this._org.region?.id, - this._org.building?.id, - zone_id, - ]).filter((_) => !!_); + const zones = assetZones(this._org, zone_id); let success_count = 0; let error_count = 0; const saved_spaces: PlaceAsset[] = []; @@ -842,15 +842,10 @@ export class ParkingStateService extends AsyncHandler { } recreate = true; } - const zones = unique([ - this._org.organisation.id, - this._org.region?.id, - this._org.building?.id, - selected_zone_id, - ]).filter((_) => !!_); + const zones = assetZones(this._org, selected_zone_id); const saved = await saveParkingSpace( space.id - ? asset_data + ? { ...asset_data, zones } : { ...asset_data, zone_id: selected_zone_id, zones }, ).catch((e) => { notifyError( diff --git a/apps/concierge/src/tests/lockers/locker-state.service.spec.ts b/apps/concierge/src/tests/lockers/locker-state.service.spec.ts index 81ec108d464..51897ab8fa8 100644 --- a/apps/concierge/src/tests/lockers/locker-state.service.spec.ts +++ b/apps/concierge/src/tests/lockers/locker-state.service.spec.ts @@ -317,6 +317,13 @@ describe('LockerStateService', () => { expect(spectator.service.lockers_banks().map((_) => _.id)).toContain( 'bank-new', ); + // banks list every zone they are in so they can be found by building + expect(ts_client.addAsset).toHaveBeenCalledWith( + expect.objectContaining({ + zone_id: 'lvl-1', + zones: expect.arrayContaining(['bld-1', 'lvl-1']), + }), + ); }); it('should tolerate malformed locker metadata', async () => { diff --git a/apps/concierge/src/tests/parking/parking-state.service.spec.ts b/apps/concierge/src/tests/parking/parking-state.service.spec.ts index 2e40a75e43a..ade19ec8786 100644 --- a/apps/concierge/src/tests/parking/parking-state.service.spec.ts +++ b/apps/concierge/src/tests/parking/parking-state.service.spec.ts @@ -488,7 +488,7 @@ describe('ParkingStateService', () => { ); }); - it('should not update zone fields when editing a parking space', async () => { + it('should keep the level but refresh the zones when editing a parking space', async () => { spectator.service.setOptions({ zones: ['lvl-selected'] }); const original_space = { id: 'space-1', @@ -523,8 +523,14 @@ describe('ParkingStateService', () => { expect(update_data).toEqual( expect.objectContaining({ id: 'space-1', bookable: false }), ); + // the space stays on its level, the modal level is not applied expect(update_data).not.toHaveProperty('zone_id'); - expect(update_data).not.toHaveProperty('zones'); + expect(update_data.zones).toEqual([ + 'org-1', + 'region-1', + 'bld-1', + 'lvl-original', + ]); }); it('should set zone fields when creating a parking space', async () => { diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index ca0c3d5121c..69b86bb345d 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -11,6 +11,7 @@ export * from './lib/locale-data'; export * from './lib/locale.service'; export * from './lib/map.interfaces'; export * from './lib/mapspeople.service'; +export * from './lib/org/asset-zones'; export * from './lib/org/building.utilities'; export * from './lib/org/organisation.service'; export * from './lib/org/organisation.utilities'; diff --git a/libs/common/src/lib/org/asset-zones.ts b/libs/common/src/lib/org/asset-zones.ts new file mode 100644 index 00000000000..9b87ca2b0f8 --- /dev/null +++ b/libs/common/src/lib/org/asset-zones.ts @@ -0,0 +1,25 @@ +import { unique } from '../general'; +import type { OrganisationService } from './organisation.service'; + +/** + * Zones an asset on `level_id` belongs to: the org, region, building and level. + * Assets are queried by any of them with the assets API `zones` filter. + */ +export function assetZones( + org: Pick & + Partial>, + level_id: string, +): string[] { + const level = org.levels?.find((item) => item.id === level_id); + // levels that are not loaded are assumed to be in the active building + const building_id = level?.parent_id || org.building?.id; + const building = + org.buildings?.find((item) => item.id === building_id) || + (org.building?.id === building_id ? org.building : undefined); + return unique([ + org.organisation?.id, + building?.parent_id || org.region?.id, + building?.id || building_id, + level_id, + ]).filter((id) => !!id); +} diff --git a/libs/common/src/tests/asset-zones.spec.ts b/libs/common/src/tests/asset-zones.spec.ts new file mode 100644 index 00000000000..9d3e9c2ed03 --- /dev/null +++ b/libs/common/src/tests/asset-zones.spec.ts @@ -0,0 +1,37 @@ +import { assetZones } from '../lib/org/asset-zones'; + +describe('assetZones', () => { + const org = { + organisation: { id: 'org-1' }, + buildings: [{ id: 'bld-1', parent_id: 'region-1' }], + levels: [{ id: 'lvl-1', parent_id: 'bld-1' }], + } as any; + + it('should list the org, region, building and level of a level', () => { + expect(assetZones(org, 'lvl-1')).toEqual([ + 'org-1', + 'region-1', + 'bld-1', + 'lvl-1', + ]); + }); + + it('should skip zones that are not known', () => { + expect( + assetZones({ ...org, buildings: [{ id: 'bld-1' }] }, 'lvl-1'), + ).toEqual(['org-1', 'bld-1', 'lvl-1']); + expect(assetZones(org, 'lvl-unknown')).toEqual([ + 'org-1', + 'lvl-unknown', + ]); + }); + + it('should use the active building for levels that are not loaded', () => { + expect( + assetZones( + { ...org, building: { id: 'bld-2', parent_id: 'region-2' } }, + 'lvl-unknown', + ), + ).toEqual(['org-1', 'region-2', 'bld-2', 'lvl-unknown']); + }); +}); From e1171d1f64a5fa5ebd578c3df764c4c127faa47b Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:41:47 +1100 Subject: [PATCH 17/28] perf(concierge): query report desk assets by building zone Desk assets list their building in `zones`, so one paginated query replaces a query per level. Co-Authored-By: Claude Opus 5.5 --- .../src/app/reports/reports-options.component.ts | 6 +++--- .../reports/reports-options.component.spec.ts | 15 ++++++++++++--- libs/assets/src/lib/desk-assets.fn.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/concierge/src/app/reports/reports-options.component.ts b/apps/concierge/src/app/reports/reports-options.component.ts index 295978428be..97bcc6bbf88 100644 --- a/apps/concierge/src/app/reports/reports-options.component.ts +++ b/apps/concierge/src/app/reports/reports-options.component.ts @@ -17,7 +17,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { - queryDeskAssetsForZones, + queryDeskAssetsInZone, queryLockerAssetsForZones, queryLockerBankAssetsForZones, queryParkingSpacesForZones, @@ -324,12 +324,12 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { resource_type: ReportResourceType, scope_id: string, ): Promise> { - // assets are queried by level, an asset's `zone_id` is always its level + // parking and locker assets are queried by level, `zone_id` is always the level const level_ids = levels.map((level) => level.id); switch (resource_type) { case 'desks': { if (this._settings.get('app.desks.use_assets')) { - const desks = await queryDeskAssetsForZones(level_ids); + const desks = await queryDeskAssetsInZone(scope_id); return this._zonesForResources( desks.filter((desk) => desk.bookable), ); diff --git a/apps/concierge/src/tests/reports/reports-options.component.spec.ts b/apps/concierge/src/tests/reports/reports-options.component.spec.ts index 30be7e39ff8..51170ebc3a0 100644 --- a/apps/concierge/src/tests/reports/reports-options.component.spec.ts +++ b/apps/concierge/src/tests/reports/reports-options.component.spec.ts @@ -157,12 +157,19 @@ describe('ReportsOptionsComponent', () => { vi.mocked(ts_client.queryAssetTypes).mockReturnValue( page([{ id: 'type-1', name: '_DESKS_' }]) as any, ); - // desk assets are on levels, `zone_id` never matches the building + // desk assets list their building in `zones`, `zone_id` is the level vi.mocked(ts_client.queryAssets).mockImplementation( (query: any) => page( - query.zone_id === 'lvl-1' - ? [{ id: 'asset-1', zone_id: 'lvl-1', bookable: true }] + query.zones === 'bld-report-1' + ? [ + { + id: 'asset-1', + zone_id: 'lvl-1', + zones: ['org-1', 'bld-report-1', 'lvl-1'], + bookable: true, + }, + ] : [], ) as any, ); @@ -175,6 +182,8 @@ describe('ReportsOptionsComponent', () => { expect([...zones]).toContain('lvl-1'); expect([...zones]).not.toContain('lvl-2'); + // one query for the building rather than one per level + expect(ts_client.queryAssets).toHaveBeenCalledTimes(1); }); it('should keep parking levels selectable when no spaces are returned', async () => { diff --git a/libs/assets/src/lib/desk-assets.fn.ts b/libs/assets/src/lib/desk-assets.fn.ts index 5ed126488b7..6735b42e572 100644 --- a/libs/assets/src/lib/desk-assets.fn.ts +++ b/libs/assets/src/lib/desk-assets.fn.ts @@ -79,6 +79,18 @@ export async function queryDeskAssets(zone_id: string): Promise { return response.data; } +/** + * Query the desk assets within a building or region. Desk assets list every + * zone they are in (org, region, building and level) in `zones`. + */ +export async function queryDeskAssetsInZone( + zone_id: string, +): Promise { + const type_id = await resolveDeskTypeId(); + const response = await queryAllAssetPages({ zones: zone_id, type_id }); + return response.data; +} + /** Query desk assets for multiple levels. */ export async function queryDeskAssetsForZones( zone_ids: string[], From a750f6d24fd7c5e9eb742fbc896bfad6954114b4 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 10:59:05 +1100 Subject: [PATCH 18/28] perf(concierge): query report parking and locker assets by building zone Assets list their building in `zones` (backfilled by init migrate:desk_assets), so one paginated query per asset type replaces a query per level. Co-Authored-By: Claude Opus 5.5 --- .../app/reports/reports-options.component.ts | 15 ++-- .../reports/reports-options.component.spec.ts | 70 +++++++++++++++++++ libs/assets/src/lib/locker-assets.fn.ts | 19 +++++ libs/assets/src/lib/parking-assets.fn.ts | 13 ++++ 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/apps/concierge/src/app/reports/reports-options.component.ts b/apps/concierge/src/app/reports/reports-options.component.ts index 97bcc6bbf88..cfdc229ecd7 100644 --- a/apps/concierge/src/app/reports/reports-options.component.ts +++ b/apps/concierge/src/app/reports/reports-options.component.ts @@ -18,9 +18,9 @@ import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { queryDeskAssetsInZone, - queryLockerAssetsForZones, - queryLockerBankAssetsForZones, - queryParkingSpacesForZones, + queryLockerAssetsInZone, + queryLockerBankAssetsInZone, + queryParkingSpacesInZone, } from '@placeos/assets'; import { AsyncHandler, @@ -324,8 +324,7 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { resource_type: ReportResourceType, scope_id: string, ): Promise> { - // parking and locker assets are queried by level, `zone_id` is always the level - const level_ids = levels.map((level) => level.id); + // assets list every zone they are in, so query by the building or region switch (resource_type) { case 'desks': { if (this._settings.get('app.desks.use_assets')) { @@ -357,13 +356,13 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { return this._zonesForResources(spaces); } case 'parking': { - const spaces = await queryParkingSpacesForZones(level_ids); + const spaces = await queryParkingSpacesInZone(scope_id); return this._zonesForResources(spaces); } case 'lockers': { const [banks, lockers] = await Promise.all([ - queryLockerBankAssetsForZones(level_ids), - queryLockerAssetsForZones(level_ids), + queryLockerBankAssetsInZone(scope_id), + queryLockerAssetsInZone(scope_id), ]); const zones = new Set(); const bookable_bank_ids = new Set( diff --git a/apps/concierge/src/tests/reports/reports-options.component.spec.ts b/apps/concierge/src/tests/reports/reports-options.component.spec.ts index 51170ebc3a0..122aa605755 100644 --- a/apps/concierge/src/tests/reports/reports-options.component.spec.ts +++ b/apps/concierge/src/tests/reports/reports-options.component.spec.ts @@ -186,6 +186,76 @@ describe('ReportsOptionsComponent', () => { expect(ts_client.queryAssets).toHaveBeenCalledTimes(1); }); + it('should find parking and locker asset levels with one building query', async () => { + vi.mocked(ts_client.queryAssets).mockClear(); + const page = (data: any[]) => + Promise.resolve({ data, total: data.length, next: () => null }); + const types = [ + { + id: 'type-parking', + name: '_PARKING_SPACES_', + category_id: 'cat-parking', + }, + { + id: 'type-banks', + name: '_LOCKER_BANKS_', + category_id: 'cat-lockers', + }, + { + id: 'type-lockers', + name: '_LOCKERS_', + category_id: 'cat-lockers', + }, + ]; + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + page([ + { id: 'cat-parking', name: '_PARKING_' }, + { id: 'cat-lockers', name: '_LOCKERS_' }, + ]) as any, + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + page(types) as any, + ); + const zones = ['org-1', 'bld-report-1', 'lvl-1']; + vi.mocked(ts_client.queryAssets).mockImplementation((query: any) => { + if (query.zones !== 'bld-report-1') return page([]) as any; + switch (query.type_id) { + case 'type-parking': + return page([ + { id: 'bay-1', zone_id: 'lvl-1', zones }, + ]) as any; + case 'type-banks': + return page([ + { id: 'bank-1', zone_id: 'lvl-1', zones }, + ]) as any; + default: + return page([ + { id: 'locker-1', parent_id: 'bank-1', bookable: true }, + ]) as any; + } + }); + const levels = [{ id: 'lvl-1' }, { id: 'lvl-2' }]; + + const parking = await ( + spectator.component as any + )._requestResourceZones(levels, 'parking', 'bld-report-1'); + const lockers = await ( + spectator.component as any + )._requestResourceZones(levels, 'lockers', 'bld-report-1'); + + expect([...parking]).toContain('lvl-1'); + expect([...lockers]).toContain('lvl-1'); + expect([...lockers]).not.toContain('lvl-2'); + // one building query per asset type rather than one per level + const queries = vi + .mocked(ts_client.queryAssets) + .mock.calls.map(([query]: any[]) => query); + expect( + queries.filter((query) => query.zones === 'bld-report-1'), + ).toHaveLength(3); + expect(queries.some((query) => query.zone_id)).toBe(false); + }); + it('should keep parking levels selectable when no spaces are returned', async () => { vi.spyOn( spectator.component as any, diff --git a/libs/assets/src/lib/locker-assets.fn.ts b/libs/assets/src/lib/locker-assets.fn.ts index 8a0101e8095..96645bae410 100644 --- a/libs/assets/src/lib/locker-assets.fn.ts +++ b/libs/assets/src/lib/locker-assets.fn.ts @@ -8,6 +8,7 @@ import { } from '@placeos/ts-client'; import { findOldestByName, + queryAllAssetPages, saveAsset, saveAssetCategory, saveAssetType, @@ -130,6 +131,15 @@ export async function queryLockerBankAssetsForZones( return flatten(results); } +/** Query the locker banks within a building or region, by their `zones` */ +export async function queryLockerBankAssetsInZone( + zone_id: string, +): Promise { + const type_id = await resolveLockerBankTypeId(); + const response = await queryAllAssetPages({ zones: zone_id, type_id }); + return response.data; +} + export async function saveLockerBankAsset( bank: Partial, ): Promise { @@ -162,6 +172,15 @@ export async function queryLockerAssetsForZones( return flatten(results); } +/** Query the lockers within a building or region, by their `zones` */ +export async function queryLockerAssetsInZone( + zone_id: string, +): Promise { + const type_id = await resolveLockerTypeId(); + const response = await queryAllAssetPages({ zones: zone_id, type_id }); + return response.data; +} + export async function saveLockerAsset( locker: Partial, ): Promise { diff --git a/libs/assets/src/lib/parking-assets.fn.ts b/libs/assets/src/lib/parking-assets.fn.ts index ca4aba55ea1..42f18079ae8 100644 --- a/libs/assets/src/lib/parking-assets.fn.ts +++ b/libs/assets/src/lib/parking-assets.fn.ts @@ -8,6 +8,7 @@ import { } from '@placeos/ts-client'; import { findOldestByName, + queryAllAssetPages, saveAsset, saveAssetCategory, saveAssetType, @@ -204,6 +205,18 @@ export async function queryParkingSpacesForZones( return flatten(results); } +/** + * Query the parking spaces within a building or region. Parking assets list + * every zone they are in (org, region, building and level) in `zones`. + */ +export async function queryParkingSpacesInZone( + zone_id: string, +): Promise { + const type_id = await resolveParkingTypeId(); + const response = await queryAllAssetPages({ zones: zone_id, type_id }); + return response.data; +} + /** Save (create or update) a parking space asset */ export async function saveParkingSpace( space: Partial, From f782152729c75c636da1b98d44aed4ced6866fcb Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:24:12 +1100 Subject: [PATCH 19/28] perf: load desk assets with building and region zone queries Desk lists, the assigned desk lookup, desk search, desk links and code check-in query desk assets by building or region `zones` instead of once per level. Co-Authored-By: Claude Opus 5.5 --- .../src/app/desks/desks-state.service.ts | 22 +++++-- .../src/app/book/code-flow.component.ts | 9 ++- .../book/desk-flow-new/desk-flow.component.ts | 8 +-- .../tests/book/code-flow.component.spec.ts | 23 +++++-- .../book/desk-flow-new.component.spec.ts | 40 +++++------ .../src/lib/booking-resource.loaders.ts | 33 ++++------ .../src/test/booking-resource.loaders.spec.ts | 66 +++++++++++++++++++ .../explore/src/lib/explore-search.service.ts | 4 +- 8 files changed, 143 insertions(+), 62 deletions(-) diff --git a/apps/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index fdfc978dcfb..8ee8d5ab361 100644 --- a/apps/concierge/src/app/desks/desks-state.service.ts +++ b/apps/concierge/src/app/desks/desks-state.service.ts @@ -14,6 +14,7 @@ import { deskFromAsset, queryDeskAssets, queryDeskAssetsForZones, + queryDeskAssetsInZone, saveDeskAsset, } from '@placeos/assets'; import { @@ -192,17 +193,19 @@ export class DesksStateService extends AsyncHandler { try { const zones = this._getActiveZones(params.zones); if (params.use_assets) { - const level_ids = - zones.length && !params.zones.includes('All') - ? zones - : this._currentLevelList().map((level) => level.id); const levels = new Map( this._currentLevelList().map((level) => [ level.id, level, ]), ); - const assets = await queryDeskAssetsForZones(level_ids); + // all levels are one query for the building or region + const assets = + zones.length && !params.zones.includes('All') + ? await queryDeskAssetsForZones(zones) + : await queryDeskAssetsInZone( + this._currentScopeId(), + ); return assets .map((asset) => deskFromAsset(asset, levels.get(asset.zone_id)), @@ -953,7 +956,7 @@ export class DesksStateService extends AsyncHandler { const email = user_email.toLowerCase(); const levels = this._currentLevelList(); const desks = this._settings.get('app.desks.use_assets') - ? await queryDeskAssetsForZones(levels.map((level) => level.id)) + ? await queryDeskAssetsInZone(this._currentScopeId()) : ( await Promise.all( levels.map((level) => @@ -1250,6 +1253,13 @@ export class DesksStateService extends AsyncHandler { ); } + /** The building, or region with `use_region`, that the level list covers */ + private _currentScopeId(): string { + return this._settings.get('app.use_region') + ? this._org.region?.id || '' + : this._org.building?.id || ''; + } + private _currentLevelList(): BuildingLevel[] { return this._settings.get('app.use_region') ? this._org.levelsForRegion(this._org.region) diff --git a/apps/workplace/src/app/book/code-flow.component.ts b/apps/workplace/src/app/book/code-flow.component.ts index 92a6299e57c..bcc271f85cf 100644 --- a/apps/workplace/src/app/book/code-flow.component.ts +++ b/apps/workplace/src/app/book/code-flow.component.ts @@ -10,7 +10,7 @@ import { viewChild, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { queryDeskAssetsForZones } from '@placeos/assets'; +import { queryDeskAssetsInZone } from '@placeos/assets'; import { BookingFormService, queryBookings, @@ -348,10 +348,9 @@ export class BookCodeFlowComponent */ private async _deskAssetId(code: string): Promise { if (!this._settings.get('app.desks.use_assets')) return code; - const levels = this._org.levelsForBuilding(); - const assets = await queryDeskAssetsForZones( - levels.map((level) => level.id), - ).catch(() => []); + const building_id = this._org.building?.id; + if (!building_id) return code; + const assets = await queryDeskAssetsInZone(building_id).catch(() => []); const asset = assets.find( (item) => item.id === code || diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts index 499e83ce6d0..85c6b1f525a 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { deskFromAsset, queryDeskAssetsForZones } from '@placeos/assets'; +import { deskFromAsset, queryDeskAssetsInZone } from '@placeos/assets'; import { BookingAsset, BookingFlowView, @@ -378,9 +378,9 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { ): Promise { for (const building of this._org.buildings || []) { const levels = this._org.levelsForBuilding(building); - const assets = await queryDeskAssetsForZones( - levels.map((level) => level.id), - ).catch(() => []); + const assets = await queryDeskAssetsInZone(building.id).catch( + () => [], + ); const desk = assets .map((asset) => deskFromAsset( diff --git a/apps/workplace/src/tests/book/code-flow.component.spec.ts b/apps/workplace/src/tests/book/code-flow.component.spec.ts index edac9a4976b..b77fa33d51e 100644 --- a/apps/workplace/src/tests/book/code-flow.component.spec.ts +++ b/apps/workplace/src/tests/book/code-flow.component.spec.ts @@ -68,8 +68,9 @@ describe('BookCodeFlowComponent', () => { ); vi.spyOn( spectator.inject(OrganisationService), - 'levelsForBuilding', - ).mockReturnValue([{ id: 'level-1' }] as any); + 'building', + 'get', + ).mockReturnValue({ id: 'bld-1' } as any); const page = (data: any[]) => Promise.resolve({ data, total: data.length, next: () => null }); vi.mocked(ts_client.queryAssetCategories).mockReturnValue( @@ -78,10 +79,20 @@ describe('BookCodeFlowComponent', () => { vi.mocked(ts_client.queryAssetTypes).mockReturnValue( page([{ id: 'type-1', name: '_DESKS_' }]) as any, ); - vi.mocked(ts_client.queryAssets).mockReturnValue( - page([ - { id: 'asset-1', zone_id: 'level-1', map_id: 'desk-1' }, - ]) as any, + // desk assets are found by building + vi.mocked(ts_client.queryAssets).mockImplementation( + (query: any) => + page( + query.zones === 'bld-1' + ? [ + { + id: 'asset-1', + zone_id: 'level-1', + map_id: 'desk-1', + }, + ] + : [], + ) as any, ); vi.mocked(ts_client.get).mockImplementation( () => Promise.resolve([{ id: 'b-1', asset_id: 'asset-1' }]) as any, diff --git a/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts b/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts index e678c999a46..69abf8ce79a 100644 --- a/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts +++ b/apps/workplace/src/tests/book/desk-flow-new.component.spec.ts @@ -1,34 +1,34 @@ import { Injector, signal, WritableSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { ActivatedRoute, Router } from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { BookingForm, - BookingFormValue, BookingFormService, + BookingFormValue, generateBookingForm, } from '@placeos/bookings'; import { Booking, EMPTY_USER, OrganisationService, - SettingsService, setCurrentUser, + SettingsService, StaffUser, User, } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; import { + authority, listChildMetadata, queryAssetCategories, queryAssets, queryAssetTypes, querySystemsWithEmails, showSystem, - authority, } from '@placeos/ts-client'; import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; import { DeskFlowNewComponent } from '../../app/book/desk-flow-new/desk-flow.component'; vi.mock('@placeos/ts-client', { spy: true }); @@ -231,9 +231,11 @@ describe('DeskFlowNewComponent', () => { expect(spectator.inject(OrganisationService).find).toHaveBeenCalledWith( 'building-1', ); - expect(spectator.inject(OrganisationService).active_building()).toEqual({ - id: 'building-1', - }); + expect(spectator.inject(OrganisationService).active_building()).toEqual( + { + id: 'building-1', + }, + ); expect(model().resources).toEqual([desk_resource]); expect(model().asset_id).toBe('desk-123'); }); @@ -294,9 +296,11 @@ describe('DeskFlowNewComponent', () => { await spectator.component.ngOnInit(); await waitFor(() => model().asset_id === 'desk-456'); - expect(spectator.inject(OrganisationService).active_building()).toEqual({ - id: 'building-2', - }); + expect(spectator.inject(OrganisationService).active_building()).toEqual( + { + id: 'building-2', + }, + ); expect(model().resources).toEqual([ expect.objectContaining({ id: 'desk-456', @@ -320,7 +324,7 @@ describe('DeskFlowNewComponent', () => { vi.mocked(queryAssets).mockImplementation( (query: any) => page( - query.zone_id === 'level-2' + query.zones === 'building-2' ? [ { id: 'asset-456', @@ -357,12 +361,10 @@ describe('DeskFlowNewComponent', () => { zone: { id: 'level-1', parent_id: 'building-1' }, }; vi.mocked(authority).mockReturnValue({} as any); - const fetch_spy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue({ - ok: true, - text: () => Promise.resolve(''), - } as any); + const fetch_spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + text: () => Promise.resolve(''), + } as any); resource_list = [desk_without_map_id]; query_params = {}; diff --git a/libs/bookings/src/lib/booking-resource.loaders.ts b/libs/bookings/src/lib/booking-resource.loaders.ts index b6b9ca9634d..2921d9335f2 100644 --- a/libs/bookings/src/lib/booking-resource.loaders.ts +++ b/libs/bookings/src/lib/booking-resource.loaders.ts @@ -2,7 +2,7 @@ import { Desk, flatten, OrganisationService } from '@placeos/common'; import { isMock, listChildMetadata } from '@placeos/ts-client'; import { deskFromAsset, - queryDeskAssetsForZones, + queryDeskAssetsInZone, } from 'libs/assets/src/lib/desk-assets.fn'; import { queryParkingSpacesForZones } from 'libs/assets/src/lib/parking-assets.fn'; import type { BookingAsset } from './booking-form.types'; @@ -29,10 +29,10 @@ function childMetadata(zone_id: string, name: string) { ); } -/** Desk assets of the given levels. Shares in-flight requests */ -function deskAssets(level_ids: string[]) { - return shareInFlight(`desk-assets|${[...level_ids].sort().join(',')}`, () => - queryDeskAssetsForZones(level_ids), +/** Desk assets within a building or region. Shares in-flight requests */ +function deskAssets(zone_id: string) { + return shareInFlight(`desk-assets|${zone_id}`, () => + queryDeskAssetsInZone(zone_id), ); } @@ -58,8 +58,9 @@ export async function loadDeskResources( org: OrganisationService, use_region: boolean, ): Promise { - const levels = use_region ? org.levelsForRegion() : org.levelsForBuilding(); - const assets = await deskAssets(levels.map((level) => level.id)); + const zone_id = use_region ? org.building?.parent_id : org.building?.id; + if (!zone_id) return []; + const assets = await deskAssets(zone_id); return assets.map((asset) => deskFromAsset(asset, org.levelWithID([asset.zone_id])), ) as BookingAsset[]; @@ -117,21 +118,13 @@ export function assignedDeskBuilding( const key = `assigned-desk|${use_assets}|${email}|${building_ids.join(',')}`; return shareInFlight(key, async () => { if (use_assets) { - const ids = new Set(building_ids); - const levels = org.levels.filter((level) => - ids.has(level.parent_id), + const lists = await Promise.all( + building_ids.map((id) => deskAssets(id).catch(() => [])), ); - const desks = await deskAssets( - levels.map((level) => level.id), - ).catch(() => []); - const desk = desks.find( - (item) => item.assigned_to?.toLowerCase() === email, - ); - if (!desk) return null; - return ( - levels.find((level) => level.id === desk.zone_id)?.parent_id || - null + const index = lists.findIndex((desks) => + desks.some((item) => item.assigned_to?.toLowerCase() === email), ); + return index >= 0 ? building_ids[index] : null; } const map_metadata = (meta) => (meta?.metadata?.desks?.details instanceof Array diff --git a/libs/bookings/src/test/booking-resource.loaders.spec.ts b/libs/bookings/src/test/booking-resource.loaders.spec.ts index c8292edf4c9..8912a62cb93 100644 --- a/libs/bookings/src/test/booking-resource.loaders.spec.ts +++ b/libs/bookings/src/test/booking-resource.loaders.spec.ts @@ -7,6 +7,7 @@ import * as ts_client from '@placeos/ts-client'; import { assignedDeskBuilding, hasAssignedDesk, + loadDeskResources, loadMetadataResources, } from '../lib/booking-resource.loaders'; @@ -60,4 +61,69 @@ describe('booking resource loaders', () => { false, ); }); + describe('desk assets', () => { + const page = (data: any[]) => + Promise.resolve({ data, total: data.length, next: () => null }); + const asset_org = { + ...org, + levelWithID: vi.fn(([id]: string[]) => ({ + id, + parent_id: 'bld-1', + })), + } as unknown as OrganisationService; + + beforeEach(() => { + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + page([{ id: 'cat-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + page([{ id: 'type-1', name: '_DESKS_' }]) as any, + ); + vi.mocked(ts_client.queryAssets).mockImplementation( + (query: any) => + page( + query.zones === 'bld-2' + ? [ + { + id: 'asset-2', + zone_id: 'lvl-2', + identifier: 'Desk 2', + assigned_to: 'Me@Example.com', + }, + ] + : query.zones === 'bld-1' + ? [ + { + id: 'asset-1', + zone_id: 'lvl-1', + identifier: 'Desk 1', + }, + ] + : [], + ) as any, + ); + }); + + it('should load the desks of the building with one query', async () => { + const desks = await loadDeskResources(asset_org, false); + + expect(desks.map((desk) => desk.id)).toEqual(['asset-1']); + expect(ts_client.queryAssets).toHaveBeenCalledTimes(1); + expect(ts_client.queryAssets).toHaveBeenCalledWith( + expect.objectContaining({ zones: 'bld-1', type_id: 'type-1' }), + ); + }); + + it('should find the building of an assigned desk asset by building query', async () => { + expect( + await assignedDeskBuilding(asset_org, 'me@example.com', true), + ).toBe('bld-2'); + expect( + vi + .mocked(ts_client.queryAssets) + .mock.calls.map(([query]: any[]) => query.zones) + .sort(), + ).toEqual(['bld-1', 'bld-2']); + }); + }); }); diff --git a/libs/explore/src/lib/explore-search.service.ts b/libs/explore/src/lib/explore-search.service.ts index c1146f09926..320d5dd5446 100644 --- a/libs/explore/src/lib/explore-search.service.ts +++ b/libs/explore/src/lib/explore-search.service.ts @@ -34,7 +34,7 @@ import { } from '@placeos/ts-client'; import { deskFromAsset, - queryDeskAssetsForZones, + queryDeskAssetsInZone, } from 'libs/assets/src/lib/desk-assets.fn'; import { toQueryString } from '@placeos/common'; @@ -302,7 +302,7 @@ export class ExploreSearchService { loader: ({ params: bld }) => { if (this._settings.get('app.desks.use_assets')) { const levels = this._org.levelsForBuilding(bld); - return queryDeskAssetsForZones(levels.map((level) => level.id)) + return queryDeskAssetsInZone(bld.id) .then((assets) => assets.map((asset) => deskFromAsset( From 82b2feec9b206714c9789da3702fb770fc4bbe59 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:26:29 +1100 Subject: [PATCH 20/28] fix(assets): load every page of parking and locker assets Co-Authored-By: Claude Opus 5.5 --- libs/assets/src/lib/locker-assets.fn.ts | 9 +++--- libs/assets/src/lib/parking-assets.fn.spec.ts | 31 +++++++++++++++++++ libs/assets/src/lib/parking-assets.fn.ts | 14 +++------ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/libs/assets/src/lib/locker-assets.fn.ts b/libs/assets/src/lib/locker-assets.fn.ts index 96645bae410..0f8e8dba206 100644 --- a/libs/assets/src/lib/locker-assets.fn.ts +++ b/libs/assets/src/lib/locker-assets.fn.ts @@ -2,7 +2,6 @@ import { flatten } from '@placeos/common'; import { PlaceAsset, queryAssetCategories, - queryAssets, queryAssetTypes, removeAsset, } from '@placeos/ts-client'; @@ -114,7 +113,7 @@ export async function queryLockerBankAssets( zone_id: string, ): Promise { const type_id = await resolveLockerBankTypeId(); - const response = await queryAssets({ zone_id, type_id, limit: 500 }); + const response = await queryAllAssetPages({ zone_id, type_id }); return response.data; } @@ -125,7 +124,7 @@ export async function queryLockerBankAssetsForZones( const type_id = await resolveLockerBankTypeId(); const results = await Promise.all( zone_ids.map((zone_id) => - queryAssets({ zone_id, type_id, limit: 500 }).then((_) => _.data), + queryAllAssetPages({ zone_id, type_id }).then((_) => _.data), ), ); return flatten(results); @@ -155,7 +154,7 @@ export async function queryLockerAssets( zone_id: string, ): Promise { const type_id = await resolveLockerTypeId(); - const response = await queryAssets({ zone_id, type_id, limit: 500 }); + const response = await queryAllAssetPages({ zone_id, type_id }); return response.data; } @@ -166,7 +165,7 @@ export async function queryLockerAssetsForZones( const type_id = await resolveLockerTypeId(); const results = await Promise.all( zone_ids.map((zone_id) => - queryAssets({ zone_id, type_id, limit: 500 }).then((_) => _.data), + queryAllAssetPages({ zone_id, type_id }).then((_) => _.data), ), ); return flatten(results); diff --git a/libs/assets/src/lib/parking-assets.fn.spec.ts b/libs/assets/src/lib/parking-assets.fn.spec.ts index 3c07c367ff8..e2259851e33 100644 --- a/libs/assets/src/lib/parking-assets.fn.spec.ts +++ b/libs/assets/src/lib/parking-assets.fn.spec.ts @@ -52,6 +52,37 @@ describe('[Parking Assets]', () => { expect(ts_client.updateAssetType).not.toHaveBeenCalled(); }); + it('should load every page of parking spaces on a level', async () => { + const { parking_assets } = await load_modules(); + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + response([{ id: 'cat-1', name: '_PARKING_' }]), + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + response([ + { + id: 'type-1', + name: '_PARKING_SPACES_', + category_id: 'cat-1', + }, + ]), + ); + vi.mocked(ts_client.queryAssets).mockReturnValue( + Promise.resolve({ + data: [{ id: 'bay-1' }], + total: 2, + next: () => + Promise.resolve({ + data: [{ id: 'bay-2' }], + total: 2, + next: () => null, + }), + }) as any, + ); + + const spaces = await parking_assets.queryParkingSpaces('level-1'); + expect(spaces.map((space) => space.id)).toEqual(['bay-1', 'bay-2']); + }); + it('should re-query the category when create collides', async () => { const { parking_assets } = await load_modules(); vi.mocked(ts_client.queryAssetCategories) diff --git a/libs/assets/src/lib/parking-assets.fn.ts b/libs/assets/src/lib/parking-assets.fn.ts index 42f18079ae8..644236290c2 100644 --- a/libs/assets/src/lib/parking-assets.fn.ts +++ b/libs/assets/src/lib/parking-assets.fn.ts @@ -2,7 +2,6 @@ import { flatten } from '@placeos/common'; import { PlaceAsset, queryAssetCategories, - queryAssets, queryAssetTypes, removeAsset, } from '@placeos/ts-client'; @@ -84,10 +83,7 @@ async function ensure_hidden_category(name: string) { return category; } catch (error) { reset_hidden_categories_cache(); - category = findOldestByName( - await query_hidden_categories(), - name, - ); + category = findOldestByName(await query_hidden_categories(), name); if (category) return category; throw error; } @@ -187,7 +183,7 @@ export async function queryParkingSpaces( zone_id: string, ): Promise { const type_id = await resolveParkingTypeId(); - const response = await queryAssets({ zone_id, type_id, limit: 500 }); + const response = await queryAllAssetPages({ zone_id, type_id }); return response.data; } @@ -199,7 +195,7 @@ export async function queryParkingSpacesForZones( const type_id = await resolveParkingTypeId(); const results = await Promise.all( zone_ids.map((zone_id) => - queryAssets({ zone_id, type_id, limit: 500 }).then((_) => _.data), + queryAllAssetPages({ zone_id, type_id }).then((_) => _.data), ), ); return flatten(results); @@ -314,7 +310,7 @@ export async function queryParkingUsers( zone_id: string, ): Promise { const type_id = await resolveParkingUserTypeId(); - const assets = await queryAssets({ zone_id, type_id, limit: 500 }); + const assets = await queryAllAssetPages({ zone_id, type_id }); return assets.data.map(toParkingUser); } @@ -406,7 +402,7 @@ export async function queryParkingFleetVehicles( zone_id: string, ): Promise { const type_id = await resolveParkingFleetTypeId(); - const assets = await queryAssets({ zone_id, type_id, limit: 500 }); + const assets = await queryAllAssetPages({ zone_id, type_id }); return assets.data.map(toParkingFleetVehicle); } From d0ec2e44ee2dd24508c2ab614c1674a634a99cd0 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:26:59 +1100 Subject: [PATCH 21/28] fix(common): include every row's columns in CSV exports Columns came from the first row only, so later rows' fields (e.g. custom desk fields) were dropped. Co-Authored-By: Claude Opus 5.5 --- libs/common/src/lib/general.ts | 6 ++++-- libs/common/src/tests/general.spec.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/libs/common/src/lib/general.ts b/libs/common/src/lib/general.ts index fe726145744..bee751a820f 100644 --- a/libs/common/src/lib/general.ts +++ b/libs/common/src/lib/general.ts @@ -271,8 +271,10 @@ export function loadTextFileFromInputEvent(event: InputEvent) { */ export function jsonToCsv(json: HashMap[], seperator = ',') { if (json instanceof Array && json.length > 0) { - const keys = Object.keys(json[0]); - const valid_keys = keys.filter((key) => key in json[0]); + // every column, rows can have different keys (e.g. custom desk fields) + const valid_keys = [ + ...new Set(json.flatMap((item) => Object.keys(item || {}))), + ]; const map_cell = (value: unknown) => { if (value === null || value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); diff --git a/libs/common/src/tests/general.spec.ts b/libs/common/src/tests/general.spec.ts index ec23af5c618..f9518061aaf 100644 --- a/libs/common/src/tests/general.spec.ts +++ b/libs/common/src/tests/general.spec.ts @@ -88,6 +88,16 @@ describe('General Methods', () => { ); }); + it('should include columns that only later rows have', () => { + const csv = jsonToCsv([ + { id: 'desk-1', name: 'Desk 1' }, + { id: 'desk-2', floorsense_id: 'fs-2' }, + ]); + expect(csv).toBe( + 'id,name,floorsense_id\r\ndesk-1,Desk 1,\r\ndesk-2,,fs-2', + ); + }); + it('should support tab-separated output', () => { const csv = jsonToCsv([{ name: 'Jane, Doe', role: 'Admin' }], '\t'); expect(csv).toBe('name\trole\r\nJane, Doe\tAdmin'); From 4b3ebe8d5b5e7db95a8da023055d8a2393324030 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:27:30 +1100 Subject: [PATCH 22/28] fix(concierge): refresh the zones of parking spaces updated by CSV Co-Authored-By: Claude Opus 5.5 --- .../src/app/parking/parking-state.service.ts | 8 +++++++ .../parking/parking-state.service.spec.ts | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/apps/concierge/src/app/parking/parking-state.service.ts b/apps/concierge/src/app/parking/parking-state.service.ts index 26fb091602f..53a5e696d80 100644 --- a/apps/concierge/src/app/parking/parking-state.service.ts +++ b/apps/concierge/src/app/parking/parking-state.service.ts @@ -740,8 +740,13 @@ export class ParkingStateService extends AsyncHandler { let success_count = 0; let error_count = 0; const saved_spaces: PlaceAsset[] = []; + const loaded_spaces = new Map( + this.spaces().map((space) => [space.id, space]), + ); for (const row of rows) { try { + // updated spaces keep their level, refresh their zone path + const existing = loaded_spaces.get(csvString(row.id)); const space_data: Partial = { ...(csvString(row.id) ? { id: csvString(row.id) } : {}), identifier: csvString(row.identifier), @@ -753,6 +758,9 @@ export class ParkingStateService extends AsyncHandler { features: csvList(row.features), notes: csvString(row.notes), ...(!csvString(row.id) ? { zone_id, zones } : {}), + ...(existing?.zone_id + ? { zones: assetZones(this._org, existing.zone_id) } + : {}), }; if (space_data.assigned_to) { await this._checkAssignedParkingLimit( diff --git a/apps/concierge/src/tests/parking/parking-state.service.spec.ts b/apps/concierge/src/tests/parking/parking-state.service.spec.ts index ade19ec8786..5f4250c1d4f 100644 --- a/apps/concierge/src/tests/parking/parking-state.service.spec.ts +++ b/apps/concierge/src/tests/parking/parking-state.service.spec.ts @@ -1436,6 +1436,29 @@ describe('ParkingStateService', () => { ); }); + it('should refresh the zones of spaces updated by CSV', async () => { + Object.defineProperty(spectator.service, 'spaces', { + value: () => [{ id: 'space-1', zone_id: 'lvl-1' }], + configurable: true, + }); + const csv = 'id,identifier,bookable\n' + 'space-1,A1,true'; + const file = new File([csv], 'spaces.csv', { type: 'text/csv' }); + const event = { + target: { files: [file], value: '' }, + } as unknown as InputEvent; + + await spectator.service.uploadSpacesCSV(event); + + const update = vi.mocked(ts_client.updateAsset).mock.calls[0][1]; + expect(update).toEqual( + expect.objectContaining({ + id: 'space-1', + zones: ['org-1', 'region-1', 'bld-1', 'lvl-1'], + }), + ); + expect(update).not.toHaveProperty('zone_id'); + }); + it('should upload numeric parking space identifiers as strings', async () => { organisation_service.levels = [ { id: 'lvl-1', parent_id: 'bld-1', tags: ['parking'] }, From c0755b9dc538994b145f0f547721df932c9d0538 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:28:53 +1100 Subject: [PATCH 23/28] test(workplace): cover finding desks near a colleague by map id Co-Authored-By: Claude Opus 5.5 --- ...auto-assigned-desk-modal.component.spec.ts | 83 ++++++++++++++++++- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/workplace/src/tests/book/auto-assigned-desk-modal.component.spec.ts b/apps/workplace/src/tests/book/auto-assigned-desk-modal.component.spec.ts index 51fa4c987cc..2fbbd29abc0 100644 --- a/apps/workplace/src/tests/book/auto-assigned-desk-modal.component.spec.ts +++ b/apps/workplace/src/tests/book/auto-assigned-desk-modal.component.spec.ts @@ -4,8 +4,11 @@ import { Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { BookingFormService } from '@placeos/bookings'; import { OrganisationService, SettingsService } from '@placeos/common'; +import * as ts_client from '@placeos/ts-client'; import { MockProvider } from 'ng-mocks'; +vi.mock('@placeos/ts-client', { spy: true }); + import { AutoAssignedDeskModalComponent } from '../../app/book/desk-flow/auto-assigned-desk-modal.component'; describe('AutoAssignedDeskModalComponent', () => { @@ -30,9 +33,9 @@ describe('AutoAssignedDeskModalComponent', () => { postForm: vi.fn(() => Promise.resolve()) as any, postFormForGroup: vi.fn(() => Promise.resolve()) as any, listResources: vi.fn(async () => [desk_resource]) as any, - listAvailableResources: vi.fn( - async () => [desk_resource], - ) as any, + listAvailableResources: vi.fn(async () => [ + desk_resource, + ]) as any, options: signal({ type: 'desk' }), resources: signal([desk_resource]), available_resources: signal([desk_resource]), @@ -81,6 +84,76 @@ describe('AutoAssignedDeskModalComponent', () => { spectator = createComponent(); }); + it('should find desks near a colleague using the desk map id', async () => { + const near = { + id: 'asset-near', + name: 'Near', + map_id: 'desk-map-near', + zone: { id: 'level-1', parent_id: 'building-1' }, + tags: [], + }; + const centre = { + id: 'asset-centre', + name: 'Centre', + map_id: 'desk-map-centre', + zone: { id: 'level-1', parent_id: 'building-1' }, + tags: [], + }; + const state = spectator.inject(BookingFormService) as any; + state.listResources.mockResolvedValue([desk_resource, near, centre]); + state.listAvailableResources.mockResolvedValue([near, centre]); + vi.mocked( + spectator.inject(OrganisationService).levelWithID, + ).mockReturnValue({ id: 'level-1', map_id: 'level-1-map.svg' } as any); + vi.mocked(ts_client.authority).mockReturnValue({} as any); + vi.mocked(ts_client.token).mockReturnValue(''); + // the colleague's desk is in a corner, beside one desk and away from the centre + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + ` + + + + `, + ), + ), + ); + // jsdom has no SVG geometry, read it from the rect attributes + const svg_proto = (window as any).SVGElement.prototype; + svg_proto.getBBox = function () { + const value = (name: string) => + Number(this.getAttribute(name)) || 0; + return { + x: value('x'), + y: value('y'), + width: value('width'), + height: value('height'), + }; + }; + // colleague bookings reference the desk asset id + spectator.setInput('nearby_desk_id', 'desk-123'); + + try { + await spectator.component.ngOnInit(); + } finally { + delete svg_proto.getBBox; + vi.unstubAllGlobals(); + // the providers are shared by every test in the file + state.listResources.mockImplementation(async () => [desk_resource]); + state.listAvailableResources.mockImplementation(async () => [ + desk_resource, + ]); + vi.mocked( + spectator.inject(OrganisationService).levelWithID, + ).mockImplementation(() => ({ id: 'level-1' }) as any); + } + + expect(spectator.component.assigned_desk()).toEqual(near); + }); + it('should search for a desk with an all-day booking', async () => { await spectator.component.ngOnInit(); @@ -98,7 +171,9 @@ describe('AutoAssignedDeskModalComponent', () => { expect(model().asset_id).toBe('desk-123'); expect(model().resources).toEqual([desk_resource]); - expect(spectator.inject(BookingFormService).postForm).toHaveBeenCalled(); + expect( + spectator.inject(BookingFormService).postForm, + ).toHaveBeenCalled(); }); it('should restore the shared form when the modal closes without a booking', async () => { From 93963c197e991df6e0e3519c2136ed10253e806a Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:29:03 +1100 Subject: [PATCH 24/28] chore: pin bun 1.4.3 to match CI and the lockfile Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e353e079676..ce222eb2f43 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@placeos/user-interfaces", "version": "1.12.0", - "packageManager": "bun@1.3.11", + "packageManager": "bun@1.4.3", "sideEffects": [ "**/user-state.ts" ], From 129d5b34aa678ab0a70f3b3446415a67efbd4fe9 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:32:50 +1100 Subject: [PATCH 25/28] test(enrolment): install test storage before ts-client loads On Node 25+ globalThis.localStorage is a getter returning undefined, so vitest skips jsdom's storage and ts-client captures undefined at import. Run the storage setup file first so it is in place before any import. Co-Authored-By: Claude Opus 5.5 --- apps/enrolment/project.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/enrolment/project.json b/apps/enrolment/project.json index 32704e6e133..195394bb6b2 100644 --- a/apps/enrolment/project.json +++ b/apps/enrolment/project.json @@ -123,7 +123,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/enrolment"], "options": { "tsConfig": "apps/enrolment/tsconfig.spec.json", - "setupFiles": ["apps/enrolment/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/enrolment/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "enrolment:test-build:development" From 312f8fd3ba6973bd3ffe3f6799adf2dcffe6c96a Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:33:54 +1100 Subject: [PATCH 26/28] fix(signage): read stored api keys through the Storage interface Object.keys only lists keys on storage that exposes them as properties, so iterate with length/key() which every Storage implementation supports. Co-Authored-By: Claude Opus 5.5 --- apps/signage/src/app/api-key.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/signage/src/app/api-key.ts b/apps/signage/src/app/api-key.ts index 355bd728c2c..518f309e290 100644 --- a/apps/signage/src/app/api-key.ts +++ b/apps/signage/src/app/api-key.ts @@ -9,8 +9,9 @@ export function hasStoredApiKey() { for (const store of [localStorage, sessionStorage]) { try { - for (const key of Object.keys(store)) { - if (key.endsWith('_x-api-key') && !!store.getItem(key)) { + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key?.endsWith('_x-api-key') && !!store.getItem(key)) { return true; } } From 30820e923a02ea70fdc8564dd9ef016f88a54717 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:35:01 +1100 Subject: [PATCH 27/28] test(map-studio): provide web storage to unit tests Node 25+ shadows jsdom's localStorage with a getter returning undefined, so install the shared in-memory storage as a setup file. Co-Authored-By: Claude Opus 5.5 --- apps/map-studio/project.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/map-studio/project.json b/apps/map-studio/project.json index 467dcbdbaa5..f4acebfc8a2 100644 --- a/apps/map-studio/project.json +++ b/apps/map-studio/project.json @@ -120,6 +120,7 @@ "outputs": ["{workspaceRoot}/coverage/apps/map-studio"], "options": { "tsConfig": "apps/map-studio/tsconfig.spec.json", + "setupFiles": ["libs/common/src/test-storage.ts"], "watch": false }, "configurations": { From 4dd38527553c89a1722481caf086ec7ea1c6e59a Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Mon, 2 Nov 2026 11:46:39 +1100 Subject: [PATCH 28/28] chore: build and test with Node 26 Node 26 is the active LTS and supported by Angular 22. Every project loads the in-memory test storage first, as Node 25+ defines an empty `localStorage`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 4 ++-- .github/workflows/e2e-advisory.yml | 2 +- .github/workflows/mobile.yml | 4 ++-- .github/workflows/pull-request.yml | 2 +- .nvmrc | 1 + apps/app-loader/project.json | 5 ++++- apps/assistant-panel/project.json | 5 ++++- apps/booking-panel/project.json | 5 ++++- apps/caterer-ui/project.json | 5 ++++- apps/concierge/project.json | 5 ++++- apps/control/project.json | 5 ++++- apps/map-kiosk/project.json | 5 ++++- apps/outlook-addin/project.json | 5 ++++- apps/public/project.json | 5 ++++- apps/redirect/project.json | 5 ++++- apps/signage-manager/project.json | 5 ++++- apps/signage/project.json | 5 ++++- apps/stagehand/project.json | 5 ++++- apps/survey/project.json | 5 ++++- apps/timetable/project.json | 5 ++++- apps/visitor-kiosk/project.json | 5 ++++- apps/workplace/project.json | 5 ++++- e2e/stack/SELF_HOSTED_RUNNER.md | 2 +- libs/assets/project.json | 5 ++++- libs/bookings/project.json | 5 ++++- libs/catering/project.json | 5 ++++- libs/common/project.json | 5 ++++- libs/components/project.json | 5 ++++- libs/events/project.json | 5 ++++- libs/explore/project.json | 5 ++++- libs/form-fields/project.json | 5 ++++- libs/map-editor/project.json | 5 ++++- libs/mocks/project.json | 5 ++++- libs/users/project.json | 5 ++++- 34 files changed, 120 insertions(+), 35 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c32f2c349c3..fb33b011919 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v6 with: - node-version: '24' + node-version: '26' - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -169,7 +169,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v6 with: - node-version: '24' + node-version: '26' - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/e2e-advisory.yml b/.github/workflows/e2e-advisory.yml index 6ffe6b77392..338428d7da4 100644 --- a/.github/workflows/e2e-advisory.yml +++ b/.github/workflows/e2e-advisory.yml @@ -123,7 +123,7 @@ jobs: # everything else combined. # # The toolchain is provisioned once on the machine instead: - # brew install colima docker docker-compose node@24 + # brew install colima docker docker-compose node@26 # See e2e/stack/SELF_HOSTED_RUNNER.md. # Keep bun-version the same as build.yml and pull-request.yml: bun.lock diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 4d66c1938eb..8be8139c4a9 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -27,7 +27,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v6 with: - node-version: '24' + node-version: '26' - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -67,7 +67,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v6 with: - node-version: '24' + node-version: '26' - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 77fc4c49167..a61d517f67e 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v6 with: - node-version: '24' + node-version: '26' - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000000..6f4247a6255 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/apps/app-loader/project.json b/apps/app-loader/project.json index 68b9004a9bc..497647f2fe4 100644 --- a/apps/app-loader/project.json +++ b/apps/app-loader/project.json @@ -91,7 +91,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/app-loader"], "options": { "tsConfig": "apps/app-loader/tsconfig.spec.json", - "setupFiles": ["apps/app-loader/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/app-loader/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "app-loader:test-build:development" diff --git a/apps/assistant-panel/project.json b/apps/assistant-panel/project.json index d96ccfcfbd9..be989945112 100644 --- a/apps/assistant-panel/project.json +++ b/apps/assistant-panel/project.json @@ -136,7 +136,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/assistant-panel"], "options": { "tsConfig": "apps/assistant-panel/tsconfig.spec.json", - "setupFiles": ["apps/assistant-panel/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/assistant-panel/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "assistant-panel:test-build:development" diff --git a/apps/booking-panel/project.json b/apps/booking-panel/project.json index e8b2062cd49..bffe4d5268a 100644 --- a/apps/booking-panel/project.json +++ b/apps/booking-panel/project.json @@ -155,7 +155,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/booking-panel"], "options": { "tsConfig": "apps/booking-panel/tsconfig.spec.json", - "setupFiles": ["apps/booking-panel/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/booking-panel/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "booking-panel:test-build:development" diff --git a/apps/caterer-ui/project.json b/apps/caterer-ui/project.json index d5c56732dc8..e96674d1161 100644 --- a/apps/caterer-ui/project.json +++ b/apps/caterer-ui/project.json @@ -121,7 +121,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/caterer-ui"], "options": { "tsConfig": "apps/caterer-ui/tsconfig.spec.json", - "setupFiles": ["apps/caterer-ui/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/caterer-ui/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "caterer-ui:test-build:development" diff --git a/apps/concierge/project.json b/apps/concierge/project.json index 69bd4ca19bd..0d54a4e9e94 100644 --- a/apps/concierge/project.json +++ b/apps/concierge/project.json @@ -126,7 +126,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/concierge"], "options": { "tsConfig": "apps/concierge/tsconfig.spec.json", - "setupFiles": ["apps/concierge/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/concierge/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "concierge:test-build:development" diff --git a/apps/control/project.json b/apps/control/project.json index af1d880c94b..66e4f596213 100644 --- a/apps/control/project.json +++ b/apps/control/project.json @@ -162,7 +162,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/control"], "options": { "tsConfig": "apps/control/tsconfig.spec.json", - "setupFiles": ["apps/control/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/control/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "control:test-build:development" diff --git a/apps/map-kiosk/project.json b/apps/map-kiosk/project.json index 4f9db02c403..563a6b6c4c2 100644 --- a/apps/map-kiosk/project.json +++ b/apps/map-kiosk/project.json @@ -121,7 +121,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/map-kiosk"], "options": { "tsConfig": "apps/map-kiosk/tsconfig.spec.json", - "setupFiles": ["apps/map-kiosk/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/map-kiosk/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "map-kiosk:test-build:development" diff --git a/apps/outlook-addin/project.json b/apps/outlook-addin/project.json index 976d63556a3..e1bc6c22c0c 100644 --- a/apps/outlook-addin/project.json +++ b/apps/outlook-addin/project.json @@ -125,7 +125,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/outlook-addin"], "options": { "tsConfig": "apps/outlook-addin/tsconfig.spec.json", - "setupFiles": ["apps/outlook-addin/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/outlook-addin/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "outlook-addin:test-build:development" diff --git a/apps/public/project.json b/apps/public/project.json index 4f588da83c8..608eea8ca3d 100644 --- a/apps/public/project.json +++ b/apps/public/project.json @@ -75,7 +75,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/public"], "options": { "tsConfig": "apps/public/tsconfig.spec.json", - "setupFiles": ["apps/public/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/public/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "public:test-build:development" diff --git a/apps/redirect/project.json b/apps/redirect/project.json index 32b70e1b282..e9f1517dbdf 100644 --- a/apps/redirect/project.json +++ b/apps/redirect/project.json @@ -82,7 +82,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/redirect"], "options": { "tsConfig": "apps/redirect/tsconfig.spec.json", - "setupFiles": ["apps/redirect/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/redirect/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "redirect:test-build:development" diff --git a/apps/signage-manager/project.json b/apps/signage-manager/project.json index 641229b4fb6..cea0e754cf4 100644 --- a/apps/signage-manager/project.json +++ b/apps/signage-manager/project.json @@ -126,7 +126,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/signage-manager"], "options": { "tsConfig": "apps/signage-manager/tsconfig.spec.json", - "setupFiles": ["apps/signage-manager/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/signage-manager/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "signage-manager:test-build:development" diff --git a/apps/signage/project.json b/apps/signage/project.json index c70b15ef21c..872dc518d5f 100644 --- a/apps/signage/project.json +++ b/apps/signage/project.json @@ -121,7 +121,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/signage"], "options": { "tsConfig": "apps/signage/tsconfig.spec.json", - "setupFiles": ["apps/signage/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/signage/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "signage:test-build:development" diff --git a/apps/stagehand/project.json b/apps/stagehand/project.json index 10707935fb4..a639a07a77d 100644 --- a/apps/stagehand/project.json +++ b/apps/stagehand/project.json @@ -133,7 +133,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/stagehand"], "options": { "tsConfig": "apps/stagehand/tsconfig.spec.json", - "setupFiles": ["apps/stagehand/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/stagehand/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "stagehand:test-build:development" diff --git a/apps/survey/project.json b/apps/survey/project.json index 67cea4278ea..6a068ee866d 100644 --- a/apps/survey/project.json +++ b/apps/survey/project.json @@ -103,7 +103,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/survey"], "options": { "tsConfig": "apps/survey/tsconfig.spec.json", - "setupFiles": ["apps/survey/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/survey/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "survey:test-build:development" diff --git a/apps/timetable/project.json b/apps/timetable/project.json index c586972c62c..7fb92d67880 100644 --- a/apps/timetable/project.json +++ b/apps/timetable/project.json @@ -126,7 +126,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/timetable"], "options": { "tsConfig": "apps/timetable/tsconfig.spec.json", - "setupFiles": ["apps/timetable/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/timetable/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "timetable:test-build:development" diff --git a/apps/visitor-kiosk/project.json b/apps/visitor-kiosk/project.json index 77975b646dd..2543c22605a 100644 --- a/apps/visitor-kiosk/project.json +++ b/apps/visitor-kiosk/project.json @@ -121,7 +121,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/visitor-kiosk"], "options": { "tsConfig": "apps/visitor-kiosk/tsconfig.spec.json", - "setupFiles": ["apps/visitor-kiosk/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/visitor-kiosk/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "visitor-kiosk:test-build:development" diff --git a/apps/workplace/project.json b/apps/workplace/project.json index 1028bb82158..9ca9444866a 100644 --- a/apps/workplace/project.json +++ b/apps/workplace/project.json @@ -160,7 +160,10 @@ "outputs": ["{workspaceRoot}/coverage/apps/workplace"], "options": { "tsConfig": "apps/workplace/tsconfig.spec.json", - "setupFiles": ["apps/workplace/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "apps/workplace/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "workplace:test-build:development" diff --git a/e2e/stack/SELF_HOSTED_RUNNER.md b/e2e/stack/SELF_HOSTED_RUNNER.md index 9a471a07951..993a1103c99 100644 --- a/e2e/stack/SELF_HOSTED_RUNNER.md +++ b/e2e/stack/SELF_HOSTED_RUNNER.md @@ -79,7 +79,7 @@ benefit on a headless box. **Colima** gives a normal `docker` + `docker compose` against a Lima VM, entirely from the CLI, startable over SSH. ```bash -brew install colima docker docker-compose node@24 +brew install colima docker docker-compose node@26 ``` Bun is not provisioned here: the workflow installs the version pinned in its diff --git a/libs/assets/project.json b/libs/assets/project.json index f092e6ede54..09e8e5c09ee 100644 --- a/libs/assets/project.json +++ b/libs/assets/project.json @@ -11,7 +11,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/assets"], "options": { "tsConfig": "libs/assets/tsconfig.spec.json", - "setupFiles": ["libs/assets/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/assets/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "assets:test-build:development" diff --git a/libs/bookings/project.json b/libs/bookings/project.json index e11ef47b2cb..145fff48b24 100644 --- a/libs/bookings/project.json +++ b/libs/bookings/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/bookings"], "options": { "tsConfig": "libs/bookings/tsconfig.spec.json", - "setupFiles": ["libs/bookings/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/bookings/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "bookings:test-build:development" diff --git a/libs/catering/project.json b/libs/catering/project.json index aeb929c0b8e..d2d8c183b7d 100644 --- a/libs/catering/project.json +++ b/libs/catering/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/catering"], "options": { "tsConfig": "libs/catering/tsconfig.spec.json", - "setupFiles": ["libs/catering/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/catering/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "catering:test-build:development" diff --git a/libs/common/project.json b/libs/common/project.json index 4a025108437..05ea7793aab 100644 --- a/libs/common/project.json +++ b/libs/common/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/common"], "options": { "tsConfig": "libs/common/tsconfig.spec.json", - "setupFiles": ["libs/common/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/common/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "common:test-build:development" diff --git a/libs/components/project.json b/libs/components/project.json index 21b081ee629..8a1af1a27a6 100644 --- a/libs/components/project.json +++ b/libs/components/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/components"], "options": { "tsConfig": "libs/components/tsconfig.spec.json", - "setupFiles": ["libs/components/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/components/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "components:test-build:development" diff --git a/libs/events/project.json b/libs/events/project.json index db67d17a4b6..e43262b7f1f 100644 --- a/libs/events/project.json +++ b/libs/events/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/events"], "options": { "tsConfig": "libs/events/tsconfig.spec.json", - "setupFiles": ["libs/events/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/events/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "events:test-build:development" diff --git a/libs/explore/project.json b/libs/explore/project.json index c43dcc1b5aa..713dc60b175 100644 --- a/libs/explore/project.json +++ b/libs/explore/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/explore"], "options": { "tsConfig": "libs/explore/tsconfig.spec.json", - "setupFiles": ["libs/explore/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/explore/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "explore:test-build:development" diff --git a/libs/form-fields/project.json b/libs/form-fields/project.json index c633f1f6618..732cb40ed5a 100644 --- a/libs/form-fields/project.json +++ b/libs/form-fields/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/form-fields"], "options": { "tsConfig": "libs/form-fields/tsconfig.spec.json", - "setupFiles": ["libs/form-fields/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/form-fields/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "form-fields:test-build:development" diff --git a/libs/map-editor/project.json b/libs/map-editor/project.json index 9bce10810ab..32a8332715d 100644 --- a/libs/map-editor/project.json +++ b/libs/map-editor/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/map-editor"], "options": { "tsConfig": "libs/map-editor/tsconfig.spec.json", - "setupFiles": ["libs/map-editor/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/map-editor/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "map-editor:test-build:development" diff --git a/libs/mocks/project.json b/libs/mocks/project.json index 101cac34360..1fc286dd372 100644 --- a/libs/mocks/project.json +++ b/libs/mocks/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/mocks"], "options": { "tsConfig": "libs/mocks/tsconfig.spec.json", - "setupFiles": ["libs/mocks/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/mocks/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "mocks:test-build:development" diff --git a/libs/users/project.json b/libs/users/project.json index 94b7e24b921..828b185b15c 100644 --- a/libs/users/project.json +++ b/libs/users/project.json @@ -12,7 +12,10 @@ "outputs": ["{workspaceRoot}/coverage/libs/users"], "options": { "tsConfig": "libs/users/tsconfig.spec.json", - "setupFiles": ["libs/users/src/test-setup.ts"], + "setupFiles": [ + "libs/common/src/test-storage.ts", + "libs/users/src/test-setup.ts" + ], "isolate": true, "watch": false, "buildTarget": "users:test-build:development"