Skip to content

fix(api): entity endpoints accept identifier form + int64 id consistency#174

Merged
unidoc-ahall merged 5 commits into
masterfrom
fix/identifier-urls-int64
Jul 14, 2026
Merged

fix(api): entity endpoints accept identifier form + int64 id consistency#174
unidoc-ahall merged 5 commits into
masterfrom
fix/identifier-urls-int64

Conversation

@unidoc-ahall

Copy link
Copy Markdown
Contributor

Closes #172 and #173.

Identifier URLs + int64. Task/incident/corrective-action/audit-finding/legal endpoints parsed the :id param with raw strconv.Atoi, so only a numeric id worked and the identifier form (TASK-6, INC-3, …) returned 400 — asymmetric with suppliers/assets/risks/systems. Now they route through parseID (extended to strip every prefix NextIdentifier mints), and those five entities' id is standardized to int64 to match the rest of the codebase so the helper drops in cleanly.

AST- → ASSET-. Suggestion-applied assets were minted AST-N while HTTP-created ones were ASSET-N. The suggestion path now uses the shared nextIdentifierTx(…, "asset") helper (→ ASSET-); parseID still strips AST- so any legacy rows keep resolving.

Regression test (tests/test_identifier_urls.py) asserts the identifier and numeric forms resolve the same record for tasks/incidents/CAs/legal (GET/PUT/DELETE), and that garbage ids still 400. go build + just test-go green.

#172, #173)

Task/incident/corrective-action/audit-finding/legal endpoints parsed :id with raw
strconv.Atoi, so only a numeric id worked and the identifier form (TASK-6, …) 400'd.
Route them through parseID (extended to strip every NextIdentifier prefix incl.
legacy AST-), and standardize those entities' id to int64 to match suppliers/
assets/risks/systems — so parseID drops in and the whole API is on one id
vocabulary.

Also fixes the AST-/ASSET- split: the suggestion-apply path now uses the shared
nextIdentifierTx(…, "asset") helper (ASSET-) instead of a hardcoded AST-.

Regression test asserts identifier and numeric URLs resolve the same record for
tasks/incidents/CAs/legal (get/put/delete). go build + go unit tests green.

Closes #172. Closes #173.
@unidoc-ahall unidoc-ahall added this to the 0.7.1 milestone Jul 14, 2026
@unidoc-ahall unidoc-ahall requested a review from unidoc-alip July 14, 2026 13:08
…174)

The identifier-URL tests failed: parseID stripped the prefix and used the numeric
SUFFIX as the primary key, but for task/incident/corrective-action/legal the
suffix is a per-org identifier_sequences value, not the id — they diverge (e.g.
after rolled-back inserts), so GET /tasks/TASK-8 looked up id=8 and 404'd.

Adds resolveTaskID/resolveIncidentID/resolveCorrectiveActionID/resolveLegalID
that pass a numeric param straight through and look up the PREFIX-<seq> form via
GetXByIdentifier (added GetTaskByIdentifier; the others already existed). Findings
keep parseID — FIND-<id> uses the id itself, so the suffix IS the key. parseID
trimmed back to the prefixes whose suffix equals the id (FIND- + the pre-existing
supplier/asset/risk/system set).

Note: the same latent bug exists in the pre-existing supplier/asset/risk/system
parseID handlers (seq != id) — out of scope here, separate issue.
@unidoc-ahall

Copy link
Copy Markdown
Contributor Author

Heads-up on a real bug the identifier-URL tests caught (thanks to the regression test): parseID stripped the prefix and treated the numeric suffix as the primary key. That only holds when the per-org identifier_sequences value happens to equal the table id. For task/incident/corrective-action/legal (minted PREFIX-<seq>) they diverge, so GET /tasks/TASK-8 was looking up id=8 → 404.

Fixed by resolving the identifier form via GetXByIdentifier lookups (resolve*ID helpers) instead of stripping. Findings keep parseID — they're FIND-<id> (the suffix is the id), so that path is correct.

Note for a separate ticket: the pre-existing supplier/asset/risk/system handlers use the same parseID-then-GetByID pattern, so they have the same latent bug when seq != id — just never surfaced because their identifier-URL path isn't exercised by tests or the SPA (which resolves from the loaded list). Out of scope for #174.

go build + just test-go green.

@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.

Solid fix, and the follow-up commit shows good judgment — the author's own regression test caught that parseID treats the numeric suffix as the PK, which only holds when the per-org identifier sequence happens to equal the id; task/incident/CA/legal now correctly resolve via GetXByIdentifier lookups instead. go build is clean and the new test file covers GET/PUT/DELETE for both id forms. Two non-blocking gaps below: a status-code inconsistency in the new resolve*ID helpers, and a pre-existing instance of the same bug class left un-flagged in the suggestion-apply path.


1. [should-fix] internal/isms/api/server.go line 319

resolveTaskID (and its incident/CA/legal siblings) return the raw GetXByIdentifier error for a syntactically valid but nonexistent identifier (e.g. GET /tasks/TASK-9999), and every call site maps that straight to 400 invalid task id. Compare with the numeric path: a nonexistent numeric id sails through this check and correctly 404s at the subsequent GetTask call (api_collab.go:2155-2157). So GET /tasks/999999 → 404 but GET /tasks/TASK-9999 → 400 for what's semantically the same "not found" case.

Suggested fix — check the identifier's own prefix before hitting the DB, so a malformed param (no digits, no recognized prefix) still 400s early, but a well-formed-but-missing identifier falls through to the existing 404 at the Get* call:

func (s *Server) resolveTaskID(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, "TASK-") {
        return 0, fmt.Errorf("%w: %q", errInvalidID, param)
    }
    t, err := s.db.GetTaskByIdentifier(ctx, orgID, param)
    if err != nil {
        return 0, err // not-found — let the caller 404, not 400
    }
    return t.ID, nil
}

Then at each call site, only 400 when the error is errInvalidID; anything else (a real GetTaskByIdentifier miss) should 404:

id, err := s.resolveTaskID(c.Request().Context(), orgID, c.Param("id"))
if errors.Is(err, errInvalidID) {
    return echo.NewHTTPError(http.StatusBadRequest, "invalid task id")
} else if err != nil {
    return echo.NewHTTPError(http.StatusNotFound, "task not found")
}

Same shape applies to resolveIncidentID ("INC-"), resolveCorrectiveActionID ("CA-"), and resolveLegalID ("LEGAL-"). errInvalidID can be a single package-level errors.New("invalid id") sentinel declared near parseID.


2. [should-fix] internal/isms/api/api_suggestions.go line 791

applyIncidentUpdate/applyCorrActiveUpdate/applyTaskUpdate still resolve sg.EntityID via parseEntityID, which strips the identifier prefix and treats the numeric suffix directly as the primary key passed to GetIncident/GetCorrectiveAction/GetTask. That's the exact bug class this PR's second commit just fixed for URL params (identifier suffix is a per-org identifier_sequences value, not the row id, for task/incident/CA/legal) — just on the suggestion-apply path instead of the HTTP :id param path. Confirmed sg.EntityID is always the identifier form here (e.g. INC-3), not a bare numeric id — the Vue side passes selectedIncident.identifier / selectedTask.identifier / selectedCA.identifier as entityId into SuggestionPanel/CommentsPanel (Incidents.vue:475, Tasks.vue:342, CorrectiveActions.vue:356), which becomes entity_id on the suggestion.

The codebase already has the correct pattern two callers away — applyRiskUpdate / applySupplierUpdate / applyLegalReading resolve via GetRiskByIdentifier / GetSupplierByIdentifier / GetLegalRequirementByIdentifier directly. Suggested fix — mirror that pattern (all three GetXByIdentifier helpers already exist, including the GetTaskByIdentifier this PR just added):

// applyIncidentUpdate
inc, err := s.db.GetIncidentByIdentifier(ctx, orgID, sg.EntityID)
if err != nil {
    return "", fmt.Errorf("incident %s not found: %w", sg.EntityID, err)
}
// drop the `parseEntityID(sg.EntityID)` call above it — incID is no longer needed

// applyCorrActiveUpdate
ca, err := s.db.GetCorrectiveActionByIdentifier(ctx, orgID, sg.EntityID)
if err != nil {
    return "", fmt.Errorf("corrective action %s not found: %w", sg.EntityID, err)
}

// applyTaskUpdate
t, err := s.db.GetTaskByIdentifier(ctx, orgID, sg.EntityID)
if err != nil {
    return "", fmt.Errorf("task %s not found: %w", sg.EntityID, err)
}

3. [nit] internal/isms/api/server.go line 306

This comment states findings and SUPPLIER-/ASSET-/RISK-/SYSTEM- identifiers "work" with parseID because the suffix IS the primary key — true for findings (FIND-<id> is built directly from the row id, per SoftDeleteAuditFinding), but for the other four nextIdentifierTx mints them from the same per-org identifier_sequences counter used for task/incident/CA/legal, so they have the same theoretical seq-vs-id divergence risk, just not yet observed/tested.

Suggested fix — soften the comment so it doesn't read as a structural guarantee:

// parseID accepts a bare numeric id, or an identifier whose numeric SUFFIX is used
// directly as the primary key: findings (FIND-<id>, built from the row id itself — see
// SoftDeleteAuditFinding) and, so far incidentally rather than by design, the
// pre-existing SUPPLIER-/ASSET-/RISK-/SYSTEM- handlers (same identifier_sequences
// mechanism as TASK-/INC-/CA-/LEGAL-, just not yet observed to diverge — see #174 PR
// discussion). It does NOT work for seq-based identifiers where the suffix is known to
// diverge from the id (TASK-/INC-/CA-/LEGAL-) — those resolve via the resolve*ID lookups
// below.

…comment

- resolve*ID: add errInvalidID sentinel + prefix check. A malformed param (no
  digits, wrong/no prefix) → 400; a well-formed-but-missing identifier returns the
  lookup error, which call sites now map to 404 — matching the numeric path.
- suggestion-apply: applyIncidentUpdate/applyCorrActiveUpdate/applyTaskUpdate
  resolved sg.EntityID via parseEntityID (suffix-as-id) — the same seq!=id bug.
  Now resolve via GetIncidentByIdentifier/GetCorrectiveActionByIdentifier/
  GetTaskByIdentifier (all exist).
- soften the parseID comment: supplier/asset/risk/system happen to be seq==id, not
  a structural guarantee.
@unidoc-ahall

Copy link
Copy Markdown
Contributor Author

Thanks @unidoc-alip — all three addressed, and a note on CI:

1. [should-fix] 404 vs 400 in resolve*ID. Added an errInvalidID sentinel + a prefix check: a malformed param (no digits, wrong/no prefix) returns errInvalidID → the call sites 400; a well-formed-but-missing identifier returns the GetXByIdentifier lookup error → 404, matching the numeric path.

2. [should-fix] suggestion-apply seq bug. applyIncidentUpdate/applyCorrActiveUpdate/applyTaskUpdate now resolve via GetIncidentByIdentifier/GetCorrectiveActionByIdentifier/GetTaskByIdentifier instead of parseEntityID-suffix. Good catch — it's actually a bit broader: change_request/objective/system/asset apply paths have the same pattern (system/asset have the helper; change/objective need one added). Filed as a follow-up (#177) rather than widen this PR.

3. [nit] parseID comment. Softened per your wording.

On CI: the red integration job is a flake, not this PR — the one failure is test_e2e_browser.py::TestTaskEditStatusPersists. That path is PUT /tasks/{numeric-id}handleUpdateTask, and for a numeric id resolveTaskID is behaviourally identical to the old strconv.Atoi (same value, same GetTask/UpdateTask). master passes this test and this PR is backend-only, so the numeric task-update path is unchanged. Re-running the job.

go build + just test-go green.

unidoc-alip
unidoc-alip previously approved these changes Jul 14, 2026

@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.

looks good to me

… form (#174)

My earlier review commit switched applyIncidentUpdate/applyCorrActiveUpdate/
applyTaskUpdate to GetXByIdentifier(sg.EntityID). But sg.EntityID isn't always the
identifier form — test_corrective_writepath / test_incident_writepath post
entity_id: str(ca_id) (the numeric id), and GetByIdentifier("7") looks up
WHERE identifier='7' → no rows → the 'corrective action 7 not found' 500 in CI.

Route through the resolve*ID helpers instead, which accept EITHER a numeric id OR
the PREFIX-<seq> identifier (numeric passes straight to Get*, identifier resolves
via GetXByIdentifier). Fixes both the numeric-entity_id tests and the SPA's
identifier-form suggestions.
@unidoc-ahall

Copy link
Copy Markdown
Contributor Author

CI fix — and it was my regression from the review commit, not a flake (thanks for pushing back on that).

My earlier commit routed suggestion-apply through GetXByIdentifier(sg.EntityID). But sg.EntityID isn't always the identifier form: test_corrective_writepath / test_incident_writepath post entity_id: str(ca_id) — the numeric id — so GetCorrectiveActionByIdentifier("7") looked up WHERE identifier='7' → no rows → the corrective action 7 not found 500. (The SPA sends the identifier form, which is why the task suggestion test passed — different form.)

Fixed by routing through the resolve*ID helpers, which accept either a numeric id or the PREFIX-<seq> identifier (numeric → straight to Get*; identifier → GetXByIdentifier) — the same both-forms resolution the URL handlers use. Covers both the numeric-entity_id tests and the identifier-form suggestions.

Note: the follow-up issue I filed for change/objective/system/asset should use this same both-forms resolve*ID pattern, not GetXByIdentifier alone. go build + just test-go green.

@unidoc-ahall unidoc-ahall merged commit a2ff9ac into master Jul 14, 2026
2 checks passed
@unidoc-ahall unidoc-ahall deleted the fix/identifier-urls-int64 branch July 14, 2026 16:13
unidoc-ahall added a commit that referenced this pull request Jul 15, 2026
* feat(tasks): per-task private visibility + org default

Tasks can be marked private: a private task is visible only to its assignee, its
creator, and managers/admins. PUBLIC by default, so existing tasks and existing
deployments are unchanged; an org opts into privacy-by-default via the
task_default_private setting.

- Migration (v0.7.1): tasks.private column + task_default_private settings-catalog
  row (renders as a boolean toggle in Admin -> Settings).
- db.TaskViewer + visibilityClause: one predicate (NOT private OR assignee=me OR
  created_by=me), applied to the task read paths (ListTasks/PaginatedTasks/
  ListTasksWhere). CanSeeAll (manager/admin) leaves queries unchanged. NO RLS.
- Single by-id fetches enforce canViewTask in the handler -> 404 (not 403) so a
  private task's existence isn't revealed. Entity-reference title resolution hides
  a private task's title from non-viewers.
- Token safety: visibility keys off the token USER's role, never a token flag, so
  a reader/contributor/agent token can't see others' private tasks; there is
  deliberately no 'see-private' token attribute (a manager/admin service user with
  a read-only token is the way to read all private tasks).
- Create form gets a Public/Private control seeded from the org default (exposed
  via /config); the API create/update accept an optional private field.
- Universal search indexes public tasks only (shared/cached index can't be
  filtered per viewer) so titles can't leak; owner-searchable private tasks are a
  tracked follow-up.

Tests: db.TaskViewer.visibilityClause + api.canViewTask (public/manager/admin/
assignee/creator visible; unrelated reader/contributor incl. agents hidden).

* fix(suggestions): resolve seq-based identifiers on apply (change/objective/system/asset) (#177)

applyChange/Objective/System/AssetUpdate resolved the target entity with
parseEntityID (strip prefix -> int) and then GetX(thatInt). The identifier
suffix is a per-org sequence, not the primary key, so on a shared/persistent
stack an identifier-form suggestion (CR-n / SYSTEM-n / ASSET-n / objective
display_id) silently hit the WRONG row — or 'not found'. Same class of bug #174
fixed for task/incident/corrective_action.

- New resolveChangeID/resolveObjectiveID/resolveSystemID/resolveAssetID: numeric
  -> id; otherwise look it up (GetChangeRequestByIdentifier [new] /
  GetObjectiveByDisplayID / GetSystemByIdentifier / GetAssetByIdentifier). These
  intentionally skip a fixed-prefix check: asset ids exist as ASSET-/AST-, systems
  as SYSTEM-, and objective identifiers are program-key display IDs (e.g. ISMS-1).
- The four apply handlers now resolve via those helpers instead of parseEntityID.
- Regression test (Python/integration): create each entity, apply an update
  addressing it by its identifier form, assert the correct row changed.

Note (separate, not fixed here): the stale-detection path (parseEntityID at the
top of the apply/get flow) has the same mis-resolution for these types, but it
only affects staleness accuracy, not which row is mutated.
unidoc-ahall added a commit that referenced this pull request Jul 15, 2026
…181)

* feat(tasks): per-task private visibility + org default

Tasks can be marked private: a private task is visible only to its assignee, its
creator, and managers/admins. PUBLIC by default, so existing tasks and existing
deployments are unchanged; an org opts into privacy-by-default via the
task_default_private setting.

- Migration (v0.7.1): tasks.private column + task_default_private settings-catalog
  row (renders as a boolean toggle in Admin -> Settings).
- db.TaskViewer + visibilityClause: one predicate (NOT private OR assignee=me OR
  created_by=me), applied to the task read paths (ListTasks/PaginatedTasks/
  ListTasksWhere). CanSeeAll (manager/admin) leaves queries unchanged. NO RLS.
- Single by-id fetches enforce canViewTask in the handler -> 404 (not 403) so a
  private task's existence isn't revealed. Entity-reference title resolution hides
  a private task's title from non-viewers.
- Token safety: visibility keys off the token USER's role, never a token flag, so
  a reader/contributor/agent token can't see others' private tasks; there is
  deliberately no 'see-private' token attribute (a manager/admin service user with
  a read-only token is the way to read all private tasks).
- Create form gets a Public/Private control seeded from the org default (exposed
  via /config); the API create/update accept an optional private field.
- Universal search indexes public tasks only (shared/cached index can't be
  filtered per viewer) so titles can't leak; owner-searchable private tasks are a
  tracked follow-up.

Tests: db.TaskViewer.visibilityClause + api.canViewTask (public/manager/admin/
assignee/creator visible; unrelated reader/contributor incl. agents hidden).

* fix(suggestions): resolve seq-based identifiers on apply (change/objective/system/asset) (#177)

applyChange/Objective/System/AssetUpdate resolved the target entity with
parseEntityID (strip prefix -> int) and then GetX(thatInt). The identifier
suffix is a per-org sequence, not the primary key, so on a shared/persistent
stack an identifier-form suggestion (CR-n / SYSTEM-n / ASSET-n / objective
display_id) silently hit the WRONG row — or 'not found'. Same class of bug #174
fixed for task/incident/corrective_action.

- New resolveChangeID/resolveObjectiveID/resolveSystemID/resolveAssetID: numeric
  -> id; otherwise look it up (GetChangeRequestByIdentifier [new] /
  GetObjectiveByDisplayID / GetSystemByIdentifier / GetAssetByIdentifier). These
  intentionally skip a fixed-prefix check: asset ids exist as ASSET-/AST-, systems
  as SYSTEM-, and objective identifiers are program-key display IDs (e.g. ISMS-1).
- The four apply handlers now resolve via those helpers instead of parseEntityID.
- Regression test (Python/integration): create each entity, apply an update
  addressing it by its identifier form, assert the correct row changed.

Note (separate, not fixed here): the stale-detection path (parseEntityID at the
top of the apply/get flow) has the same mis-resolution for these types, but it
only affects staleness accuracy, not which row is mutated.

* fix(suggestions): confirm a submitted suggestion is in review (#167)

Submitting a suggestion via SuggestNewButton closed the form and emitted
'created' with no feedback; on a list view the suggestion isn't shown there, so
it felt like it vanished. Both create paths (SuggestNewButton and the
SuggestionPanel inline form) now show a success toast: 'Suggestion submitted — a
manager will review it before it takes effect.'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API: entity endpoints accept identifier form (TASK-6), not just numeric id (+ int64 id consistency)

2 participants