Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d658d54
fix(explore): match desk bookings by asset id on the map
stakach Nov 1, 2026
0cbb682
fix(assets): keep custom desk fields in desk asset other_data
stakach Nov 1, 2026
050ded5
fix(bookings): migrate favourite desks to desk asset ids
stakach Nov 1, 2026
b22e0c6
fix(explore): locate desk search results by map id
stakach Nov 1, 2026
09f62fc
fix(workplace): find desks near a colleague by desk map id
stakach Nov 1, 2026
bdba6de
fix(concierge): find report levels with desk, parking and locker assets
stakach Nov 1, 2026
f2f9145
fix(concierge): update existing desk assets on desk import
stakach Nov 1, 2026
2369d4e
fix(concierge): check desk asset map ids for duplicates and booking n…
stakach Nov 1, 2026
5f78d8a
fix(concierge): protect desk asset map ids in the map manager
stakach Nov 1, 2026
4c4e186
fix(common): match booking rule resource ids against desk map ids
stakach Nov 1, 2026
58b67db
fix(assets): load every page of desk assets and retry the desk type l…
stakach Nov 1, 2026
2515693
fix(workplace): resolve desk links to desk assets
stakach Nov 1, 2026
7fc9045
fix(workplace): check in to desk assets from codes with the original …
stakach Nov 1, 2026
8b2880f
fix(concierge): collect new desk asset details before saving
stakach Nov 1, 2026
c493063
test(assets): import Desk statically in desk asset spec
stakach Nov 1, 2026
00539e4
fix(concierge): save the full zone path on parking and locker assets
stakach Nov 1, 2026
e1171d1
perf(concierge): query report desk assets by building zone
stakach Nov 1, 2026
a750f6d
perf(concierge): query report parking and locker assets by building zone
stakach Nov 1, 2026
f782152
perf: load desk assets with building and region zone queries
stakach Nov 2, 2026
82b2fee
fix(assets): load every page of parking and locker assets
stakach Nov 2, 2026
d0ec2e4
fix(common): include every row's columns in CSV exports
stakach Nov 2, 2026
4b3ebe8
fix(concierge): refresh the zones of parking spaces updated by CSV
stakach Nov 2, 2026
c0755b9
test(workplace): cover finding desks near a colleague by map id
stakach Nov 2, 2026
93963c1
chore: pin bun 1.4.3 to match CI and the lockfile
stakach Nov 2, 2026
129d5b3
test(enrolment): install test storage before ts-client loads
stakach Nov 2, 2026
312f8fd
fix(signage): read stored api keys through the Storage interface
stakach Nov 2, 2026
30820e9
test(map-studio): provide web storage to unit tests
stakach Nov 2, 2026
4dd3852
chore: build and test with Node 26
stakach Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-advisory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26
5 changes: 4 additions & 1 deletion apps/app-loader/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion apps/assistant-panel/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion apps/booking-panel/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion apps/caterer-ui/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion apps/concierge/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
91 changes: 66 additions & 25 deletions apps/concierge/src/app/desks/desks-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
deskFromAsset,
queryDeskAssets,
queryDeskAssetsForZones,
queryDeskAssetsInZone,
saveDeskAsset,
} from '@placeos/assets';
import {
Expand All @@ -29,6 +30,7 @@ import {
updateBooking,
} from '@placeos/bookings';
import {
assetZones,
AsyncHandler,
Booking,
BuildingLevel,
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -509,7 +518,8 @@ export class DesksStateService extends AsyncHandler {
// written with its own desks.
const groups = new Map<string, Desk[]>();
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;
Expand All @@ -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;
}
Expand All @@ -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<Desk>);
}

/** Remove one desk from the configured resource store. */
public async removeDesk(desk: Desk, zone_id: string) {
if (this._settings.get('app.desks.use_assets')) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions apps/concierge/src/app/desks/desks-topbar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
notifyInfo,
OrganisationService,
randomInt,
SettingsService,
} from '@placeos/common';
import { IconComponent, TranslatePipe } from '@placeos/components';
import { DateOptionsComponent } from '@placeos/form-fields';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
Expand Down
23 changes: 18 additions & 5 deletions apps/concierge/src/app/lockers/locker-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
updateBooking,
} from '@placeos/bookings';
import {
assetZones,
AsyncHandler,
Booking,
getTimezoneDifferenceInHours,
Expand Down Expand Up @@ -79,14 +80,15 @@ const removeToken = (l: string, t: string) => l.replace(t, '');
function lockerBankToAsset(
bank: Partial<LockerBank>,
zone_id: string,
zones: string[],
): Partial<PlaceAsset> {
return {
...(bank.id ? { id: bank.id } : {}),
identifier: bank.name || '',
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 || '',
Expand All @@ -101,13 +103,14 @@ function lockerBankToAsset(
function lockerToAsset(
locker: Partial<Locker>,
zone_id: string,
zones: string[],
): Partial<PlaceAsset> {
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 || '',
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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 &&
Expand Down
Loading
Loading