Skip to content

feat(ui): identifier deep-links + copy-link button on all modules (#166)#185

Open
unidoc-ahall wants to merge 2 commits into
masterfrom
feat/deep-links-copy-link
Open

feat(ui): identifier deep-links + copy-link button on all modules (#166)#185
unidoc-ahall wants to merge 2 commits into
masterfrom
feat/deep-links-copy-link

Conversation

@unidoc-ahall

Copy link
Copy Markdown
Contributor

Closes #166

What

Every register detail view now (a) uses the entity's human identifier in the URL and (b) has a copy-link button — so a link to any item is shareable and stable.

How

  • CopyLinkButton.vue (new): copies the current page URL. Secure-context navigator.clipboard with an execCommand fallback for plain-http self-hosted boxes (mirrors the code-copy handler in App.vue). Dropped into the detail header of all 12 module views.
  • Identifier URLs: selectX navigates to the identifier (…/risks/RISK-3, …/tasks/TASK-6; objectives → display_id, programs → key) instead of the numeric DB id.
  • Open by identifier: openXFromRoute matches by identifier in-memory and passes the raw param to the GET-by-id endpoints (which already resolve identifier-or-numeric, fix(api): entity endpoints accept identifier form + int64 id consistency #174/Suggestion-apply: resolve seq-based identifiers via lookup (change/objective/system/asset) #177) instead of parseInt, so deep-links resolve for off-page items too; the route.params watcher guards were updated to match.
  • Backend: handleGetObjective now routes through resolveObjectiveID, so GET /objectives/<display_id> resolves (it was numeric-only) — the last off-page gap.

Scope notes

Create-time navigation still lands on the numeric id (the item is on-page, so it opens fine; a tiny cosmetic follow-up). Audit gets the copy-link button but keeps numeric ids in its special /audit/:tab/:itemId routing — identifier routing there is a small separate follow-up.

Testing

  • tests/test_objective_display_id_url.py: GET /objectives/<display_id> resolves the same objective as the numeric id.
  • just build-web, just build-go, just test-go pass. Frontend deep-link/copy behaviour verified manually (can't be unit-tested).

…ules (#166)

Detail views now use the entity's human identifier in the URL and expose a
copy-link button, so a link to any item is shareable and stable.

- New CopyLinkButton.vue: copies the current URL (secure-context clipboard with an
  execCommand fallback for plain-http self-hosted boxes, mirroring App.vue).
  Added to every module's detail header (12 views).
- selectX now navigates to the identifier (…/risks/RISK-3, …/tasks/TASK-6;
  objectives use display_id, programs use key) instead of the numeric id.
- openXFromRoute matches by identifier (in-memory) and passes the raw param to the
  identifier-capable GET endpoints (#174/#177) instead of parseInt, so identifier
  deep-links resolve for off-page items too; watcher guards updated to match.
- handleGetObjective now routes through resolveObjectiveID, so GET
  /objectives/<display_id> resolves (was numeric-only) — closes the last off-page
  gap. Create-nav and Audit's /audit/:tab/:itemId keep numeric ids (Audit gets the
  copy-link button; its identifier routing is a small follow-up).

Test: tests/test_objective_display_id_url.py (GET by display_id resolves the same
objective as numeric). just build-web / build-go / test-go pass.
@unidoc-ahall unidoc-ahall added this to the 0.7.1 milestone Jul 15, 2026
@unidoc-ahall unidoc-ahall added the enhancement New feature or request label Jul 15, 2026
@unidoc-ahall unidoc-ahall requested a review from unidoc-alip July 15, 2026 16:05

@unidoc-alip unidoc-alip left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good frontend work overall — CopyLinkButton, the identifier-based navigation
across the 9 views, and the resolveObjectiveID wiring for objectives all look
correct. The security pass turned up nothing (parameterized queries, org-scoping
preserved on every read, no unsafe DOM sinks in CopyLinkButton.vue).

However, the PR description's central claim — "deep-links resolve for off-page
items too... the GET-by-id endpoints already resolve identifier-or-numeric" — does
not hold for 3 of the 9 duplicated views. The frontend now sends raw identifiers
(RISK-3, ASSET-7, CR-12) to backend GET handlers that still only accept a bare
numeric id, so opening one of these as a fresh tab / shared link / page reload 400s
and the item silently never loads. Two more views resolve via prefix-stripping
instead of a DB lookup, which is the same wrong-row bug class PR #180 already fixed
for suggestion-apply, just not applied to these GET handlers.


1. [must-fix] internal/isms/api/server.go line 1855 (handleGetAsset)

Parses c.Param("id") with strconv.ParseInt directly, so it never accepts the
identifier form. Assets.vue's openItemFromRoute now sends the raw identifier
(e.g. ASSET-7) to GET /api/v1/assets/:id whenever the item isn't already
loaded — that request 400s, the fetch is swallowed by the try/catch, and the
detail pane silently never opens. A resolveAssetID helper that does the
identifier lookup already exists (server.go:3259-3266) but isn't wired into this
handler.

func (s *Server) handleGetAsset(c echo.Context) error {
    orgID := getOrgID(c)
    id, err := s.resolveAssetID(c.Request().Context(), orgID, c.Param("id"))
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "asset not found")
    }
    a, err := s.db.GetAsset(c.Request().Context(), orgID, id)
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "asset not found")
    }
    return c.JSON(http.StatusOK, a)
}

2. [must-fix] internal/isms/api/server.go line 2053 (handleGetRisk)

Same issue as Assets, and there's no resolveRiskID helper anywhere in the
codebase (unlike Task/Incident/CorrectiveAction/Legal, which each have one).
Risks.vue's openItemFromRoute now sends RISK-3-style identifiers for
off-page items, which 400s. GetRiskByIdentifier already exists
(db/risks.go:320) and delegates to GetRisk, so this is a small new resolver
plus wiring, not new DB work.

func (s *Server) resolveRiskID(ctx context.Context, orgID int, param string) (int64, error) {
    if n, err := strconv.ParseInt(param, 10, 64); err == nil {
        return n, nil
    }
    if !strings.HasPrefix(param, "RISK-") {
        return 0, errInvalidID
    }
    r, err := s.db.GetRiskByIdentifier(ctx, orgID, param)
    if err != nil {
        return 0, err
    }
    return r.ID, nil
}

func (s *Server) handleGetRisk(c echo.Context) error {
    orgID := getOrgID(c)
    id, err := s.resolveRiskID(c.Request().Context(), orgID, c.Param("id"))
    if errors.Is(err, errInvalidID) {
        return echo.NewHTTPError(http.StatusBadRequest, "invalid risk id")
    } else if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "risk not found")
    }
    r, err := s.db.GetRisk(c.Request().Context(), orgID, id)
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "risk not found")
    }
    return c.JSON(http.StatusOK, r)
}

3. [must-fix] internal/isms/api/api_collab.go line 2508 (handleGetChange)

Parses c.Param("id") with strconv.Atoi directly. Changes.vue's
openChangeFromRoute now sends raw identifiers (CR-12) for off-page items,
which 400s. A resolveChangeID helper (server.go:3226-3233, backed by
GetChangeRequestByIdentifier) already exists and is used by suggestion-apply,
but isn't wired into this handler. Note it returns int64 while
db.GetChangeRequest takes int, so the id needs an explicit cast.

func (s *Server) handleGetChange(c echo.Context) error {
    orgID := getOrgID(c)
    id, err := s.resolveChangeID(c.Request().Context(), orgID, c.Param("id"))
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "change request not found")
    }
    cr, err := s.db.GetChangeRequest(c.Request().Context(), orgID, int(id))
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "change request not found")
    }
    return c.JSON(http.StatusOK, cr)
}

4. [should-fix] internal/isms/api/server.go line 1983 (handleGetSystem)

Resolves :id via parseID, which strips the fixed SYSTEM- prefix and parses
the remainder as the numeric primary key — no DB lookup. This is the same bug
class PR #180 fixed for suggestion-apply on change/objective/system/asset: the
per-org identifier sequence can diverge from the primary key on a
shared/persistent stack, so SYSTEM-5 could silently resolve to row id 5
instead of the row whose identifier is actually SYSTEM-5. Unlike Assets/Risk/
Changes above this won't 400 — it will quietly serve the wrong system once the
sequences drift. A resolveSystemID helper (DB lookup, server.go:3248-3256)
already exists but is unused here.

func (s *Server) handleGetSystem(c echo.Context) error {
    orgID := getOrgID(c)
    id, err := s.resolveSystemID(c.Request().Context(), orgID, c.Param("id"))
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "system not found")
    }
    sys, err := s.db.GetSystem(c.Request().Context(), orgID, id)
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "system not found")
    }
    return c.JSON(http.StatusOK, sys)
}

5. [should-fix] internal/isms/api/server.go line 2383 (handleGetSupplier)

Same prefix-strip issue as Systems above, and there isn't even a
resolveSupplierID helper to switch to yet. GetSupplierByIdentifier already
exists (db/suppliers.go:307-314) and delegates to GetSupplier, so a new
resolver is a direct copy of resolveAssetID's shape.

func (s *Server) resolveSupplierID(ctx context.Context, orgID int, param string) (int64, error) {
    if n, err := strconv.ParseInt(param, 10, 64); err == nil {
        return n, nil
    }
    sup, err := s.db.GetSupplierByIdentifier(ctx, orgID, param)
    if err != nil {
        return 0, err
    }
    return sup.ID, nil
}

func (s *Server) handleGetSupplier(c echo.Context) error {
    orgID := getOrgID(c)
    id, err := s.resolveSupplierID(c.Request().Context(), orgID, c.Param("id"))
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "supplier not found")
    }
    sup, err := s.db.GetSupplier(c.Request().Context(), orgID, id)
    if err != nil {
        return echo.NewHTTPError(http.StatusNotFound, "supplier not found")
    }
    return c.JSON(http.StatusOK, sup)
}

6. [nit] internal/isms/api/api_objectives.go line 380 (handleGetObjective)

Returns 404 for both a malformed :id and a well-formed-but-missing display_id,
where the Task/Incident/CorrectiveAction/Legal resolvers distinguish via
errInvalidID and return 400 for malformed input. Not a functional bug, and it
actually matches the existing resolveChangeID/resolveSystemID/
resolveAssetID family design (which skips that distinction on purpose since
those entities have no fixed prefix to validate against). No action recommended
— flagging only for visibility.

Follow-up to alip's #185 review: this PR made the SPA send raw identifiers to the
GET-by-id endpoints, but several still only took a numeric id (or prefix-stripped),
so off-page deep-links 400'd or served the wrong row.

- handleGetAsset: wired the existing resolveAssetID.
- handleGetRisk: new resolveRiskID (backed by GetRiskByIdentifier) + wired.
- handleGetChange: wired resolveChangeID (int64 -> int cast for GetChangeRequest).
- handleGetSystem: parseID prefix-strip -> resolveSystemID (DB lookup) — fixes the
  wrong-row-on-seq-drift class PR #180 fixed for suggestion-apply.
- handleGetSupplier: new resolveSupplierID (backed by GetSupplierByIdentifier),
  same DB-lookup switch.

Test: tests/test_deep_link_get_by_identifier.py (parametrized risk/asset/change/
system/supplier — GET by identifier resolves the same row as numeric). build-go /
test-go green.
@unidoc-ahall

Copy link
Copy Markdown
Contributor Author

You're right — my "the GET-by-id endpoints already resolve identifier-or-numeric" claim didn't hold for these handlers, so the deep-links this PR introduced would 400 (or serve the wrong row) for off-page items. All six addressed:

1. handleGetAsset — [must-fix] ✅ Wired the existing resolveAssetID.
2. handleGetRisk — [must-fix] ✅ Added resolveRiskID (backed by the existing GetRiskByIdentifier) and wired it.
3. handleGetChange — [must-fix] ✅ Wired resolveChangeID, with the int64int cast for GetChangeRequest you noted.
4. handleGetSystem — [should-fix] ✅ Swapped the parseID prefix-strip for resolveSystemID (DB lookup) — fixes the wrong-row-on-seq-drift.
5. handleGetSupplier — [should-fix] ✅ Added resolveSupplierID (backed by GetSupplierByIdentifier) and switched to the DB lookup.
6. handleGetObjective 404-vs-400 — [nit] Left as-is per your note — it matches the resolveChange/System/Asset family (no fixed-prefix distinction), and the new resolveRisk/SupplierID follow the same shape for consistency.

Test: tests/test_deep_link_get_by_identifier.py parametrizes over risk / asset / change / system / supplier and asserts GET /<module>/<identifier> resolves the same row as the numeric id. just fmt / build-go / test-go pass.

Good catch — this was the one part of the PR that couldn't be caught by build-web.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shareable deep-links: use entity identifiers in URLs + copy-link button on all modules

2 participants