Browse Source

feat(iot-hub): restore /iot-hub/{itemId} published deep link

Reintroduces the canonical item link (/iot-hub/{itemId}) that resolves
to the latest published version. The preview shape (/iot-hub/{itemId}/preview)
stays removed — if a creator wants to preview a specific draft, they
share the /iot-hub/version/{itemVersionId} link with that exact
versionId.

Final URL shapes:
- /iot-hub/{itemId}                 → latest published version
- /iot-hub/version/{itemVersionId}  → specific version, warning if unpublished

Resolver branches on which route param is present — no extra data flag.
Restores getPublishedVersion API method. IoT Hub ask adds back one new
endpoint: GET /api/items/{itemId}/published.
pull/15539/head
Andrii Shvaika 1 month ago
parent
commit
365cac6728
  1. 15
      docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md
  2. 96
      docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md
  3. 7
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  4. 20
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts
  5. 8
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts

15
docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md

@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship `/iot-hub/version/{itemVersionId}` deep links that resolve a specific version, gate unpublished versions behind a security warning, navigate to the type-specific browse page, and open the existing detail dialog.
**Goal:** Ship `/iot-hub/{itemId}` (latest published version of an item) and `/iot-hub/version/{itemVersionId}` (specific version snapshot, warning gate if unpublished) deep links that resolve a version, navigate to the type-specific browse page, and open the existing detail dialog.
**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints.
@ -924,15 +924,16 @@ If the smoke test uncovered issues that required fixes, stage and commit them wi
## IoT Hub-side changes required (recap)
No new endpoints. Only behavior + CORS contracts on the existing `/api/versions/{versionId}` family:
One new endpoint plus behavior + CORS contracts on the existing by-versionId family:
1. **`GET /api/versions/{versionId}`** must return the requested version regardless of state (PUBLISHED / DRAFT / PENDING_REVIEW / …). Anonymous cross-origin; versionId UUID is the soft-secret gate.
2. **`MpItemVersionView`** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`0`/`null`) for non-published versions, or add an explicit `state` field. Frontend's `isPublished()` uses `publishedTime > 0` today.
3. **Related by-versionId endpoints** must also serve unpublished versions (required for install-from-deep-link):
1. **New endpoint** `GET /api/items/{itemId}/published` — latest PUBLISHED version as `MpItemVersionView`; `404` if none. Anonymous cross-origin. Powers the `/iot-hub/{itemId}` URL.
2. **`GET /api/versions/{versionId}`** must return the requested version regardless of state (PUBLISHED / DRAFT / PENDING_REVIEW / …). Anonymous cross-origin; versionId UUID is the soft-secret gate. Powers the `/iot-hub/version/{itemVersionId}` URL.
3. **`MpItemVersionView`** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`0`/`null`) for non-published versions, or add an explicit `state` field. Frontend's `isPublished()` uses `publishedTime > 0` today.
4. **Related by-versionId endpoints** must also serve unpublished versions (required for install-from-deep-link):
- `GET /api/versions/{versionId}/readme`
- `GET /api/versions/{versionId}/fileData`
- `POST /api/versions/{versionId}/install`
4. **Install counter policy** for unpublished versions — recommended: skip counting.
5. **CORS** on the `/api/versions/{versionId}/...` family must permit cross-origin GET from any origin.
5. **Install counter policy** for unpublished versions — recommended: skip counting.
6. **CORS** on `/api/items/{itemId}/published` and the `/api/versions/{versionId}/...` family must permit cross-origin GET from any origin.
These live in the IoT Hub repository, not ThingsBoard CE.

96
docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md

@ -7,24 +7,37 @@
## Summary
Allow a URL of the form `http://<tb-host>/iot-hub/version/{itemVersionId}` to open the detail view for a specific IoT Hub item version. The link works for published and unpublished versions alike; unpublished versions are gated behind a security warning before the detail view opens. Intended for creators sharing stable snapshots ("review exactly this draft") and for bookmarkable deep links to any version.
Allow two shareable deep-link URL shapes:
- `http://<tb-host>/iot-hub/{itemId}` — opens the detail view for the latest published version of the item.
- `http://<tb-host>/iot-hub/version/{itemVersionId}` — opens a specific version snapshot. Published versions open directly; unpublished versions are gated behind a security warning.
The first URL is the canonical "share this marketplace item" link. The second targets creator review workflows — stable snapshots of an exact draft.
## Goals
- A single shareable, bookmarkable deep link shape: `/iot-hub/version/{itemVersionId}`.
- Published versions open directly.
- Unpublished versions (DRAFT / PENDING_REVIEW / …) open only after the user acknowledges a security warning, and the detail dialog then shows an "Unpublished preview" badge.
- Two shareable, bookmarkable deep link shapes with distinct semantics:
- `/iot-hub/{itemId}` — always latest published version of the item (404 if none published).
- `/iot-hub/version/{itemVersionId}` — specific version; warning gate when unpublished, then "Unpublished preview" badge in the detail dialog.
- Zero backend changes in ThingsBoard. Install/update flows reuse the existing versionId-based pipeline.
## Non-goals
- Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the version UUID can fetch it.
- Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the UUID can fetch the content.
- Full standalone page for item detail. The detail view stays as an Angular Material dialog; the deep link navigates to the type-specific browse page (`/iot-hub/widgets`, `/iot-hub/dashboards`, etc.) and opens the dialog over it.
- Install-specific semantics for unpublished versions. Install reuses the normal flow; only the IoT Hub-side install counter policy may differ (see IoT Hub-side changes).
- Item-level "latest" shorthand links (no `/iot-hub/{itemId}` or `/iot-hub/{itemId}/preview`). Creators share specific version ids; stable links are preferred over moving-target "latest" semantics.
- A "latest including drafts" shorthand shape (`/iot-hub/{itemId}/preview`). If creators want to preview a specific draft, they use the version URL with the exact versionId.
## User flows
### Published link: `/iot-hub/{itemId}`
1. User pastes or clicks `/iot-hub/{itemId}`.
2. Angular mounts `TbIotHubItemResolverComponent`.
3. Resolver calls `iotHubApiService.getPublishedVersion(itemId)` → IoT Hub `GET /api/items/{itemId}/published` (new endpoint).
4. On success, resolver navigates to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge.
5. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`.
6. On 404 (no published version exists) or other error, resolver shows a toast and redirects to `/iot-hub`.
### Version link: `/iot-hub/version/{itemVersionId}`
1. User pastes or clicks `/iot-hub/version/{itemVersionId}`.
@ -34,20 +47,21 @@ Allow a URL of the form `http://<tb-host>/iot-hub/version/{itemVersionId}` to op
5. If the returned version is unpublished → resolver opens `TbIotHubUnpublishedWarningDialogComponent` (`disableClose: true`).
- Cancel → `router.navigate(['/iot-hub'])`.
- "I understand the risk, continue" → navigates to `/iot-hub/{typeSegment(item.type)}` with `{ openItem: { version, preview: true } }`; detail dialog opens with the "Unpublished preview" badge.
6. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`.
7. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end.
8. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`.
6. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end.
7. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`.
## Angular routing
One route added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, placed after the existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`):
Two routes added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, placed after the existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`). The literal `version/:itemVersionId` must come **before** the `:itemId` wildcard so the router matches the literal first:
```ts
{ path: 'version/:itemVersionId', component: TbIotHubItemResolverComponent,
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } },
{ path: ':itemId', component: TbIotHubItemResolverComponent,
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } },
```
A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id produces a friendly toast instead of a generic not-found page.
The resolver branches on which param is present — `paramMap.get('itemVersionId')` signals the version-URL flow; `paramMap.get('itemId')` signals the published-URL flow. A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id produces a friendly toast instead of a generic not-found page.
## Components
@ -58,14 +72,15 @@ Location: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.compo
- Standalone: `false`. Declared in `IotHubModule`.
- Template: empty (`template: ''`). The component renders nothing; it is a router-reachable controller.
- `ngOnInit`:
1. Read `itemVersionId` from route params.
1. Read `itemVersionId` and `itemId` from route params. Set `byVersion = itemVersionId != null`; pick the relevant id accordingly.
2. Reject non-UUID id → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`.
3. Call `getVersionInfo(itemVersionId, { ignoreErrors: true })`.
3. Dispatch to `getVersionInfo(id)` (byVersion) or `getPublishedVersion(id)` (published), both with `{ ignoreErrors: true }`.
4. On error, map HTTP status to `iot-hub.deep-link-not-found` (404) or `iot-hub.deep-link-fetch-failed` (other) and redirect.
5. On success, call `handleResolved(version)`:
- If version is unpublished → open warning dialog; confirm routes to type-page with state (`preview: true`); cancel routes to `/iot-hub`.
5. On success, call `handleResolved(version, byVersion)`:
- `byVersion` + version unpublished → open warning dialog; confirm routes to type-page with state (`preview: true`); cancel routes to `/iot-hub`.
- Otherwise → route directly to type-page with state (`preview: false`).
- All navigations use `replaceUrl: true` so the resolver URL does not pollute browser history.
- The published URL (`/iot-hub/{itemId}`) cannot surface unpublished content — `getPublishedVersion` only returns PUBLISHED versions — so the warning branch is unreachable for that flow. The `byVersion` gate in `handleResolved` makes this explicit.
### `iot-hub-deep-link.utils.ts` (new)
@ -174,13 +189,22 @@ private resolveInstalledItem(v: MpItemVersionView): Observable<IotHubInstalledIt
## API contract
### `IotHubApiService`
### `IotHubApiService` — one new method
```ts
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
```
No new methods. The resolver uses the existing `getVersionInfo(versionId, config)` which calls `GET /api/versions/{versionId}` on IoT Hub. The resolver passes `{ ignoreErrors: true }` so it can handle failures inline rather than surfacing the global interceptor toast.
The version URL reuses the existing `getVersionInfo(versionId, config)` which calls `GET /api/versions/{versionId}`. Both accept `{ ignoreErrors: true }` so the resolver can handle failures inline rather than surfacing the global interceptor toast.
### ThingsBoard backend
Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; the version-link flow funnels into them without modification.
Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; both deep-link flows funnel into them without modification.
## i18n
@ -197,24 +221,27 @@ Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into ot
## Edge cases
- **Invalid UUID shape** for `itemVersionId``iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`.
- **404 from IoT Hub** (version doesn't exist or was removed) → `iot-hub.deep-link-not-found` toast + redirect.
- **Invalid UUID shape** for either `itemId` or `itemVersionId``iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`.
- **404 from IoT Hub**:
- Published URL: item has no published version, or item doesn't exist. Toast `iot-hub.deep-link-not-found` + redirect.
- Version URL: version doesn't exist or was removed. Same toast + redirect.
- **Network / 5xx error**`iot-hub.deep-link-fetch-failed` toast + redirect.
- **Version URL resolves to a published version** → no warning, no badge. Serves as a stable snapshot link to that version.
- **Unsupported `ItemType`** (future value not in `typeSegment`) → treated as `iot-hub.deep-link-fetch-failed`.
- **User hits Browser Back from the warning dialog** → dialog destroys with the resolver component; no zombie dialog.
- **User lacks `TENANT_ADMIN`** → the `/iot-hub` parent route guard blocks; no additional guard needed.
- **Version URL for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Creators can test update and install-one-more flows end-to-end.
- **Deep link for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Both URL shapes produce identical behavior once the dialog is open.
- **Refresh after deep link has been resolved** → URL is now `/iot-hub/{typePage}`; `history.state.openItem` is cleared; user sees the type-page with no dialog (expected).
## Testing
- `TbIotHubItemResolverComponent` unit tests with mocked `IotHubApiService` and `Router`:
- published version happy path (no warning, no badge)
- unpublished version happy path (warning → confirm → dialog with badge)
- unpublished version, warning cancel → redirect to `/iot-hub`
- invalid UUID
- 404
- published URL happy path (no warning, no badge)
- version URL happy path, version is published (no warning, no badge)
- version URL happy path, version is unpublished (warning → confirm → dialog with badge)
- version URL, warning cancel → redirect to `/iot-hub`
- invalid UUID (either param)
- 404 (both URL shapes)
- 5xx / network error
- unsupported `ItemType`
- `isPublished()` and `typeSegment()` unit tests.
@ -245,13 +272,18 @@ Modified:
## IoT Hub-side changes required
These live in the IoT Hub repository, not ThingsBoard CE. No new endpoints are required — only behavior and CORS contracts on the existing `/api/versions/{versionId}` family.
These live in the IoT Hub repository, not ThingsBoard CE. One new endpoint plus behavior/CORS contracts on the existing by-versionId family.
1. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate.
2. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today.
3. **Related by-versionId endpoints must also serve unpublished versions** (required by the install flow proxied through TB):
1. **New endpoint** `GET /api/items/{itemId}/published`
- Returns `MpItemVersionView` for the latest version of the item that is in the PUBLISHED state.
- `404` when the item has no published version or doesn't exist.
- Anonymous cross-origin access (same CORS policy as `/api/versions/published`).
- Powers the `/iot-hub/{itemId}` deep link.
2. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate.
3. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today.
4. **Related by-versionId endpoints must also serve unpublished versions** (required by the install flow proxied through TB):
- `GET /api/versions/{versionId}/readme`
- `GET /api/versions/{versionId}/fileData`
- `POST /api/versions/{versionId}/install`
4. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests.
5. **CORS**: ensure the `/api/versions/{versionId}/...` family permits cross-origin GET from any origin (same policy as `/api/versions/published`).
5. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests.
6. **CORS**: ensure `/api/items/{itemId}/published` and the full `/api/versions/{versionId}/...` family permit cross-origin GET from any origin.

7
ui-ngx/src/app/core/http/iot-hub-api.service.ts

@ -114,6 +114,13 @@ export class IotHubApiService {
);
}
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable<string> {
return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, {
params: this.buildParams(config),

20
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts

@ -51,15 +51,23 @@ export class TbIotHubItemResolverComponent implements OnInit {
) {}
ngOnInit(): void {
const itemVersionId = this.route.snapshot.paramMap.get('itemVersionId');
const params = this.route.snapshot.paramMap;
const itemVersionId = params.get('itemVersionId');
const itemId = params.get('itemId');
const byVersion = itemVersionId != null;
const id = byVersion ? itemVersionId : itemId;
if (!isUUID(itemVersionId)) {
if (!isUUID(id)) {
this.failTo('iot-hub.deep-link-invalid-id');
return;
}
this.iotHubApi.getVersionInfo(itemVersionId, { ignoreErrors: true }).subscribe({
next: v => this.handleResolved(v),
const fetch$ = byVersion
? this.iotHubApi.getVersionInfo(id, { ignoreErrors: true })
: this.iotHubApi.getPublishedVersion(id, { ignoreErrors: true });
fetch$.subscribe({
next: v => this.handleResolved(v, byVersion),
error: err => {
const key = err?.status === 404
? 'iot-hub.deep-link-not-found'
@ -69,14 +77,14 @@ export class TbIotHubItemResolverComponent implements OnInit {
});
}
private handleResolved(version: MpItemVersionView): void {
private handleResolved(version: MpItemVersionView, byVersion: boolean): void {
const segment = typeSegment(version.type);
if (!segment) {
this.failTo('iot-hub.deep-link-fetch-failed');
return;
}
const unpublished = !isPublished(version);
const unpublished = byVersion && !isPublished(version);
if (unpublished) {
this.dialog.open<

8
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts

@ -147,6 +147,14 @@ const routes: Routes = [
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-detail'
}
},
{
path: ':itemId',
component: TbIotHubItemResolverComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-detail'
}
}
]
}

Loading…
Cancel
Save