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/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index f272384ec81..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 { @@ -29,6 +30,7 @@ import { updateBooking, } from '@placeos/bookings'; import { + assetZones, AsyncHandler, Booking, BuildingLevel, @@ -191,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)), @@ -309,11 +313,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]; @@ -509,7 +518,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 +529,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 +558,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')) { @@ -588,9 +629,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; @@ -911,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) => @@ -1111,18 +1156,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) { @@ -1219,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/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/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/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/app/parking/parking-state.service.ts b/apps/concierge/src/app/parking/parking-state.service.ts index b44a1b74949..53a5e696d80 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,17 +736,17 @@ 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[] = []; + 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( @@ -842,15 +850,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/app/reports/reports-options.component.ts b/apps/concierge/src/app/reports/reports-options.component.ts index fa3e5c96d8b..cfdc229ecd7 100644 --- a/apps/concierge/src/app/reports/reports-options.component.ts +++ b/apps/concierge/src/app/reports/reports-options.component.ts @@ -17,10 +17,10 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { - queryDeskAssetsForZones, - queryLockerAssetsForZones, - queryLockerBankAssetsForZones, - queryParkingSpacesForZones, + queryDeskAssetsInZone, + queryLockerAssetsInZone, + queryLockerBankAssetsInZone, + queryParkingSpacesInZone, } from '@placeos/assets'; import { AsyncHandler, @@ -324,10 +324,11 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { resource_type: ReportResourceType, scope_id: string, ): Promise> { + // 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')) { - const desks = await queryDeskAssetsForZones([scope_id]); + const desks = await queryDeskAssetsInZone(scope_id); return this._zonesForResources( desks.filter((desk) => desk.bookable), ); @@ -355,13 +356,13 @@ export class ReportsOptionsComponent extends AsyncHandler implements OnInit { return this._zonesForResources(spaces); } case 'parking': { - const spaces = await queryParkingSpacesForZones([scope_id]); + const spaces = await queryParkingSpacesInZone(scope_id); return this._zonesForResources(spaces); } case 'lockers': { const [banks, lockers] = await Promise.all([ - queryLockerBankAssetsForZones([scope_id]), - queryLockerAssetsForZones([scope_id]), + queryLockerBankAssetsInZone(scope_id), + queryLockerAssetsInZone(scope_id), ]); const zones = new Set(); const bookable_bank_ids = new Set( @@ -384,6 +385,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/desks/desks-state.service.spec.ts b/apps/concierge/src/tests/desks/desks-state.service.spec.ts index b943c82206d..94ff3b37637 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' }); @@ -406,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), @@ -552,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 () => { 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(); 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/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( 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..5f4250c1d4f 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 () => { @@ -1430,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'] }, 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..122aa605755 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,115 @@ 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 list their building in `zones`, `zone_id` is the level + vi.mocked(ts_client.queryAssets).mockImplementation( + (query: any) => + page( + query.zones === 'bld-report-1' + ? [ + { + id: 'asset-1', + zone_id: 'lvl-1', + zones: ['org-1', 'bld-report-1', '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'); + // one query for the building rather than one per level + 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/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/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" 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/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": { 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/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; } } 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/apps/workplace/src/app/book/code-flow.component.ts b/apps/workplace/src/app/book/code-flow.component.ts index 93f61e30d66..bcc271f85cf 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 { queryDeskAssetsInZone } 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,24 @@ 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 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 || + 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/app/book/desk-flow-new/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts index 7760e823f9c..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,6 +9,7 @@ import { } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { deskFromAsset, queryDeskAssetsInZone } 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 queryDeskAssetsInZone(building.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/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) { 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 () => { 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..b77fa33d51e 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,53 @@ 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), + '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( + 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 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, + ); + 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', + ]); + }); }); 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..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,11 +1,11 @@ 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 { @@ -13,18 +13,22 @@ import { EMPTY_USER, OrganisationService, 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 }); @@ -227,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'); }); @@ -290,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', @@ -303,6 +311,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.zones === 'building-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', @@ -311,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/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/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 bd9509b688a..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 }); @@ -86,6 +87,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({ @@ -119,6 +158,79 @@ 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 = 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..6735b42e572 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,19 @@ 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; +} + +/** + * 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; } @@ -80,7 +99,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, ), ), @@ -88,19 +107,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 +209,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, }; } diff --git a/libs/assets/src/lib/locker-assets.fn.ts b/libs/assets/src/lib/locker-assets.fn.ts index 8a0101e8095..0f8e8dba206 100644 --- a/libs/assets/src/lib/locker-assets.fn.ts +++ b/libs/assets/src/lib/locker-assets.fn.ts @@ -2,12 +2,12 @@ import { flatten } from '@placeos/common'; import { PlaceAsset, queryAssetCategories, - queryAssets, queryAssetTypes, removeAsset, } from '@placeos/ts-client'; import { findOldestByName, + queryAllAssetPages, saveAsset, saveAssetCategory, saveAssetType, @@ -113,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; } @@ -124,12 +124,21 @@ 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); } +/** 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 { @@ -145,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; } @@ -156,12 +165,21 @@ 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); } +/** 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.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 ca4aba55ea1..644236290c2 100644 --- a/libs/assets/src/lib/parking-assets.fn.ts +++ b/libs/assets/src/lib/parking-assets.fn.ts @@ -2,12 +2,12 @@ import { flatten } from '@placeos/common'; import { PlaceAsset, queryAssetCategories, - queryAssets, queryAssetTypes, removeAsset, } from '@placeos/ts-client'; import { findOldestByName, + queryAllAssetPages, saveAsset, saveAssetCategory, saveAssetType, @@ -83,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; } @@ -186,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; } @@ -198,12 +195,24 @@ 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); } +/** + * 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, @@ -301,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); } @@ -393,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); } 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/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/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-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) => { 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/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/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/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/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/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']); + }); +}); 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/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'); 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/explore/src/lib/explore-desks.service.ts b/libs/explore/src/lib/explore-desks.service.ts index 58359048602..2462871d2c9 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( { @@ -270,6 +272,7 @@ export class ExploreDesksService extends AsyncHandler implements OnDestroy { host: currentUser(), resource: { id, + map_id, zones: [level?.parent_id, level?.id], }, }, @@ -591,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], }, }, diff --git a/libs/explore/src/lib/explore-search.service.ts b/libs/explore/src/lib/explore-search.service.ts index 0fb337fba22..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'; @@ -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, @@ -287,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( @@ -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-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(() => ({ 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: '' }, 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" 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" ],