feat(ui): identifier deep-links + copy-link button on all modules (#166)#185
feat(ui): identifier deep-links + copy-link button on all modules (#166)#185unidoc-ahall wants to merge 2 commits into
Conversation
…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-alip
left a comment
There was a problem hiding this comment.
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.
|
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. Test: Good catch — this was the one part of the PR that couldn't be caught by |
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-contextnavigator.clipboardwith anexecCommandfallback for plain-http self-hosted boxes (mirrors the code-copy handler inApp.vue). Dropped into the detail header of all 12 module views.selectXnavigates to the identifier (…/risks/RISK-3,…/tasks/TASK-6; objectives →display_id, programs →key) instead of the numeric DB id.openXFromRoutematches 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 ofparseInt, so deep-links resolve for off-page items too; theroute.paramswatcher guards were updated to match.handleGetObjectivenow routes throughresolveObjectiveID, soGET /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/:itemIdrouting — 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-gopass. Frontend deep-link/copy behaviour verified manually (can't be unit-tested).