fix(dashboard): wire upcoming-purchase buttons to plan endpoints (closes #204, #205) by cristim · Pull Request #207 · LeanerCloud/cloud-commitments-cli · GitHub
Skip to content

fix(dashboard): wire upcoming-purchase buttons to plan endpoints (closes #204, #205) - #207

Merged
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/upcoming-purchases-plan-id
Apr 30, 2026
Merged

cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/upcoming-purchases-plan-id

Conversation

@cristim

@cristim cristim commented Apr 30, 2026 •

Copy link
Copy Markdown
Member

Real fix for the dashboard "Upcoming Scheduled Purchases" 500-on-click pair (#204, #205). Supersedes the surface symptom that PR #206 cleared.

What was actually broken

internal/api/handler_dashboard.go:upcomingFromPlan emitted the PurchasePlan.ID as the JSON field execution_id. The frontend bound that to View Details / Cancel buttons which called execution-level endpoints (/api/purchases/{id}, /api/purchases/cancel/{id}). Those endpoints look the value up in purchase_executions, but no execution row exists yet for an upcoming purchase — the execution is created when the schedule fires. Every click 500-ed.

PR #206 cleared the column-doesn't-exist symptom. After that, the same buttons surfaced the underlying logic error directly:

[ERROR] API error: execution not found:
        execution not found: 1cf88fc4-b48e-4303-9ba0-32f000ccaf31

(1cf88fc4-… was a plan_id all along.)

Backend

Frontend

  • types.ts + api/types.ts: UpcomingPurchase.execution_id → plan_id.
  • dashboard.ts:
    • Added upcomingPurchasesIndex: Map<string, UpcomingPurchase> populated by renderUpcomingPurchases. The dialog renders from this snapshot — no API call is needed (every field is in the upcoming-list response) and there's no execution row to fetch.
    • Buttons now carry data-id="${p.plan_id}".
    • viewPurchaseDetails(planId) is now sync; the modal is built via DOM API (createElement + textContent) instead of innerHTML. Closes a class of XSS regressions and avoids reliance on the escapeHtml-by-convention pattern.
    • cancelScheduledPurchase(planId) calls api.deletePlannedPurchase(planId) instead of api.cancelPurchase(executionId).
    • In-modal cancel button likewise routes to deletePlannedPurchase.
  • dashboard.test.ts: fixtures + mocks updated; the now-impossible "API rejection" failure-mode test for view-details was removed (the call path is sync); cancel tests assert deletePlannedPurchase is called with the plan_id.

Verification

  • go build ./... clean
  • go test ./... — every package green (incl. handler_dashboard_test.go)
  • tsc --noEmit clean
  • npx jest — 1386/1386 tests pass, including the 18 dashboard tests
  • Pre-commit (gosec / trivy / migration-conflicts / go-test / build frontend / frontend tests) — all green

Once deployed, the two dashboard buttons will hit the right endpoints and stop returning 500s.

Triage

type/bug, severity/high, urgency/now, impact/all-users, effort/s, priority/p0, triaged. Closes #204 + #205 — the column-doesn't-exist fix in #206 is still correct (defends against any future migration drift), but this PR is what actually makes the buttons work.

Summary by CodeRabbit

  • Improvements
    • Refactored upcoming purchases management to use plan identifiers for more reliable action handling.
    • Optimized purchase details view to load from local data, eliminating unnecessary API calls.
    • Updated purchase cancellation to operate at the plan level for better consistency.

… exec endpoints (closes #204, #205)

The dashboard's "Upcoming Scheduled Purchases" panel rendered "View
Details" / "Cancel" buttons whose data-id was a PurchasePlan.ID and
hit /api/purchases/{id} and /api/purchases/cancel/{id} — execution-
level endpoints. Those endpoints look the value up in
purchase_executions, where no row exists yet for an upcoming purchase
(the execution is created when the schedule fires). Result: every
click 500'd.

PR #206's idempotent migration cleared a downstream symptom (the
column-doesn't-exist failure mode), but the upstream data-flow bug
remained:

  CloudWatch (post-#206):
    [ERROR] API error: execution not found:
            execution not found: 1cf88fc4-b48e-4303-9ba0-32f000ccaf31
    [ERROR] API error: failed to get execution:
            execution not found: 1cf88fc4-b48e-4303-9ba0-32f000ccaf31

(`1cf88fc4-...` was a plan_id all along.)

## Backend

* `internal/api/types.go`: `UpcomingPurchase.ExecutionID` →
  `UpcomingPurchase.PlanID`; JSON tag `execution_id` → `plan_id`. Doc
  comment now warns that wiring this to execution-level endpoints is
  exactly the bug pattern from #204/#205.
* `internal/api/handler_dashboard.go:upcomingFromPlan`: emit
  `PlanID: plan.ID` instead of `ExecutionID: plan.ID`.
* `internal/api/handler_dashboard_test.go`: assertion updated.

## Frontend

* `types.ts` + `api/types.ts`: `UpcomingPurchase.execution_id` →
  `plan_id`.
* `dashboard.ts`:
  - Added an in-memory `Map<string, UpcomingPurchase>` populated by
    `renderUpcomingPurchases`. The dialog renders from this snapshot —
    no API call is needed because the upcoming-list response carries
    every field the dialog displays, and there's no execution row to
    fetch yet.
  - Buttons now carry `data-id="${p.plan_id}"`.
  - `viewPurchaseDetails(planId)` is sync; renders the modal via DOM
    API (createElement + textContent) instead of innerHTML, which
    closes a class of XSS regressions and avoids the project's
    innerHTML security hook.
  - `cancelScheduledPurchase(planId)` calls
    `api.deletePlannedPurchase(planId)` instead of
    `api.cancelPurchase(executionId)`.
  - The in-modal "Cancel Purchase" button (when the modal is open)
    likewise routes to `deletePlannedPurchase`.
* `dashboard.test.ts`: fixtures and mocks updated; the now-impossible
  "API rejection" failure-mode test for view-details was removed (the
  call path is sync); cancel tests assert `deletePlannedPurchase` is
  called with the plan_id.

## Verification

* `go build ./...` clean
* `go test ./...` — every package green
* `tsc --noEmit` clean
* `npx jest` — 1386/1386 tests pass (incl. the 18 dashboard tests)

Closes #204
Closes #205
@cristim cristim added type/bug Defect severity/high Significant harm urgency/now Drop other things impact/all-users Affects every user effort/s Hours priority/p0 Drop everything; same-day fix triaged Item has been triaged labels Apr 30, 2026
@cristim

cristim commented Apr 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai

coderabbitai Bot commented Apr 30, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4b915ee2-74e9-4f79-b569-e2c48801f6ed

📥 Commits

Reviewing files that changed from the base of the PR and between 4b24af5 and 3a681ab.

📒 Files selected for processing (7)
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/api/types.ts
  • frontend/src/dashboard.ts
  • frontend/src/types.ts
  • internal/api/handler_dashboard.go
  • internal/api/handler_dashboard_test.go
  • internal/api/types.go

📝 Walkthrough

Walkthrough

The pull request refactors how the dashboard identifies and manages upcoming scheduled purchases by switching from execution_id to plan_id as the primary identifier. This change affects type definitions across frontend and backend, updates the dashboard component to store and render purchase details from in-memory data rather than fetching via API, and modifies the cancellation flow to use the new plan-level endpoint.

Changes

Cohort / File(s) Summary
Type Definitions
frontend/src/api/types.ts, frontend/src/types.ts, internal/api/types.go
Interface and struct updated to use plan_id instead of execution_id for UpcomingPurchase; backend type comment expanded to clarify plan-level endpoint usage.
Frontend Dashboard
frontend/src/dashboard.ts, frontend/src/__tests__/dashboard.test.ts
Dashboard component refactored to key actions by plan_id, index upcoming purchases in-memory, render details from cached data instead of API calls, and switch cancellation from api.cancelPurchase(executionId) to api.deletePlannedPurchase(planId); test fixtures and assertions updated accordingly with removal of getPurchaseDetails mocking.
Backend Handlers
internal/api/handler_dashboard.go, internal/api/handler_dashboard_test.go
Response mapping for upcoming purchases updated to assign plan identifier to PlanID field; test assertion adjusted to verify the new field.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

effort/m

Poem

🐰 A plan ID hops where execution once did roam,
We cache the details, bring them closer to home!
No more API calls on each details peek,
The dashboard now renders the data we seek.
From cancel to delete, the flow finds its way,
Upcoming purchases sorted a better way! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main change: wiring upcoming-purchase buttons from execution endpoints to plan endpoints, with direct issue references.
Linked Issues check ✅ Passed PR addresses the root cause of issue #204 by changing the data model so dashboard buttons use plan endpoints (deletePlannedPurchase) instead of execution endpoints, directly resolving the misrouting that caused cancellation failures.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the data-flow bug: identifier rename (execution_id → plan_id), endpoint wiring updates, test updates, and in-memory indexing for modal rendering. No refactoring or unrelated modifications present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/upcoming-purchases-plan-id

Review rate limit: 3/5 reviews remaining, refill in 16 minutes and 14 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 0112510 into feat/multicloud-web-frontend Apr 30, 2026
4 checks passed
cristim added a commit that referenced this pull request Apr 30, 2026
…ase (CR pass 1)

Self-correction: PR #207's first pass routed Cancel to
api.deletePlannedPurchase, but that endpoint operates on
purchase_executions and would 404 on a plan_id (the same data-flow
mismatch this PR was trying to fix, just shifted from /api/purchases/
cancel to /api/purchases/planned).

The right plan-level destructive operation is DELETE /api/plans/{id}
-> Handler.deletePlan(planID), which calls config.DeletePurchasePlan
and stops all future scheduled steps.

Changes:
* dashboard.ts cancelScheduledPurchase + in-modal Cancel: switch to
  api.deletePlan(planId).
* Confirm-dialog body updated to make the destructive scope explicit
  ("deletes the plan and stops all future scheduled steps").
* dashboard.test.ts: added deletePlan to the api mock; assertions
  switched to expect(api.deletePlan).toHaveBeenCalledWith('plan-123').

View Details path is unchanged (already correct: renders from the
in-memory upcomingPurchasesIndex, no API call). The "missing data"
edge case still degrades gracefully — toast on miss, return early.

If you'd prefer a soft-disable that preserves the plan record, swap
to api.patchPlan(planId, { enabled: false }) — one-line change. Going
with deletePlan to match the user-mental-model of "Cancel" as a
destructive stop.

Verification:
* go test ./... -- all green
* tsc --noEmit -- clean
* npx jest -- 1386/1386 pass (18 dashboard tests)
@cristim

cristim commented Apr 30, 2026

Copy link
Copy Markdown
Member Author

Self-correction — pushed a2b6c75de (3a681ab03 → a2b6c75de).

The first commit on this PR routed Cancel to api.deletePlannedPurchase, but that endpoint operates on purchase_executions and would 404 on a plan_id — the same data-flow mismatch this PR was trying to fix, just shifted from /api/purchases/cancel/{id} to /api/purchases/planned/{id}. View Details would have worked, Cancel would have stayed broken.

Now: Cancel calls api.deletePlan(planId) which hits DELETE /api/plans/{id} → Handler.deletePlan → config.DeletePurchasePlan. That's the actual plan-level destructive operation; stops all future scheduled steps; cleanly handles "plan not found" via requirePlanAccess.

Confirm-dialog body updated to make the destructive scope explicit ("deletes the plan and stops all future scheduled steps").

If a soft-disable is preferred (preserve the plan record, allow re-enable), swap one line to api.patchPlan(planId, { enabled: false }). Going with deletePlan because the user-mental-model of clicking "Cancel" on a scheduled item is a destructive stop, not a pause.

Edge cases now (in answer to the question that surfaced this):

  • View Details + plan_id missing from the in-memory index (e.g. stale render): toast "Failed to load purchase details: not in current view" and return — graceful.
  • View Details + plan_id present: renders from local data, no API call. Always works.
  • Cancel + plan deleted between dashboard load and click: deletePlan returns NotFound via requirePlanAccess → toast "Failed to cancel purchase", no 5xx.
  • Cancel + plan present: deletes; reload; success toast.

Verification:

  • go test ./... — full suite green
  • tsc --noEmit — clean
  • npx jest — 1386/1386 pass (18 dashboard tests, including the cancel-path tests now asserting expect(api.deletePlan).toHaveBeenCalledWith("plan-123"))
  • Pre-commit (gosec / trivy / migration-conflicts / build frontend / frontend tests) — green locally

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

cristim added a commit that referenced this pull request Apr 30, 2026
…ec-id} (#213)

* fix(dashboard): cancel upcoming via DELETE /api/plans, not /api/purchases/planned

PR #207 wired the dashboard "Upcoming Scheduled Purchases" Cancel button
to api.deletePlannedPurchase(planId), but that frontend helper hits
DELETE /api/purchases/planned/{id}, whose backend handler still operates
on purchase_executions rows via TransitionExecutionStatus. Passing a
PurchasePlan.ID matches no execution → 4xx → toast "Failed to cancel
purchase".

CloudWatch (post-#207 deploy):

  [ERROR] API error: failed to get execution:
          execution not found: 1cf88fc4-b48e-4303-9ba0-32f000ccaf31

(`1cf88fc4-…` is a plan_id.)

Switch the two callsites in dashboard.ts (inline Cancel button +
in-modal Cancel-Purchase button) to api.deletePlan, which targets
DELETE /api/plans/{id} → deletePlanHandler → DeletePurchasePlan.
That's the right endpoint for "stop this plan from firing its next
upcoming step".

Tests: dashboard.test.ts updated to assert deletePlan is called and
deletePlannedPurchase is not. All 1411 frontend tests pass.

Closes #208 (assumed; if filed under a different number, re-link).

* fix(dashboard): cancel upcoming via DELETE /api/purchases/planned/{exec-id}, not /api/plans/{plan-id}

Replaces the entire approach in this PR. The previous draft routed
Cancel to api.deletePlan(planId) — that deleted the whole plan,
which is too aggressive: operators clicking "Cancel" on an upcoming
scheduled step usually want to skip THAT step, not nuke the
recurring template.

The right model: enumerate pending purchase_executions on the backend
and emit one upcoming row per execution. That gives the frontend a
real execution_id to target via DELETE /api/purchases/planned/{id}
(the existing deletePlannedPurchase handler operating on
purchase_executions) — Cancel removes just the scheduled instance,
the plan stays in place.

Backend (handler_dashboard.go + types.go):

* getUpcomingPurchases switches from ListPurchasePlans iteration to
  GetPendingExecutions. Uses the same planMap + isPlanAllowedCached
  pattern as getPlannedPurchases (handler_purchases.go) so the
  dashboard widget and the Plans page walk the same canonical
  "what's about to happen" set.
* upcomingFromPlan -> upcomingFromExecution. Step number now comes
  from the execution row directly (already stamped at instance-create
  time) instead of plan.RampSchedule.CurrentStep+1.
* UpcomingPurchase gains ExecutionID; PlanID kept for context.
* Orphan executions (parent plan deleted) are hidden, not crashed —
  cleanup of those is a separate concern.

Frontend (dashboard.ts + types.ts + api/types.ts):

* UpcomingPurchase TS interface gains execution_id.
* upcomingPurchasesIndex now keyed by execution_id.
* Buttons carry data-id="${p.execution_id}".
* viewPurchaseDetails(executionId) renders from the in-memory
  snapshot — the modal also shows the plan ID + execution ID so
  operators can cross-reference.
* cancelScheduledPurchase(executionId) calls
  api.deletePlannedPurchase(executionId). The confirm-dialog body
  rewritten to make the limited scope explicit ("removes this
  upcoming step from the plan; the plan itself stays in place").
* In-modal Cancel button likewise routes to deletePlannedPurchase.

Tests:

* handler_dashboard_test.go: TestHandler_getUpcomingPurchases now
  mocks GetPendingExecutions; asserts one-row-per-execution semantics
  (plan with no pending exec doesn't appear; two executions of the
  same plan produce two rows). Added _OrphanExecutionSkipped guard.
* handler_dashboard_test.go: scoped-user + unattributed tests now
  also mock GetPendingExecutions to drive the new code path.
* handler_test.go: integration test for /api/dashboard/upcoming
  updated likewise.
* dashboard.test.ts: fixtures gain execution_id; cancel-path tests
  assert deletePlannedPurchase('exec-123') AND deletePlan NOT called
  (regression guard against the previous wrong-direction fix).

Verification: go test ./... full-repo green; tsc --noEmit clean;
npx jest 1411/1411 pass; pre-commit (gocyclo / gosec / trivy /
migration-conflicts / go-test / build-frontend / frontend-tests) all
green.
cristim added a commit that referenced this pull request Oct 7, 2026
…pe fixes

Rollout relays for cloud-commitments-go #218 (fix(azure): stop dividing
the monthly run-rate by the term months, merged as e24345c) and #207
(fix(azure): report only savings plans billed to the subscription, merged
as 58c25f0). Pin providers/azure at v0.0.0-20261007133317-58c25f04c49b,
which contains both fixes, and pkg at the version the azure module
requires. The aws and gcp pins stay where they are so no module moves
backwards.

Error-handling audit for the #207 semantics change (GetExistingCommitments
for Azure savings plans can now fail instead of returning a silently
partial list): every CLI call to GetExistingCommitments goes through
provider.ServiceClient instances built by createServiceClient, which
dispatches AWS services only, so no CLI path calls the Azure inventory
today. The duplicate-purchase guards that do consume the interface
(checkDuplicates, checkDuplicatesForCSVRegion) already fail closed on
error for purchase runs.

Coordinated with #2121: that issue's broader deps adoption should build on
this branch or exclude the azure pin.
cristim added a commit that referenced this pull request Oct 7, 2026
…pe fixes (#2142)

Rollout relays for cloud-commitments-go #218 (fix(azure): stop dividing
the monthly run-rate by the term months, merged as e24345c) and #207
(fix(azure): report only savings plans billed to the subscription, merged
as 58c25f0). Pin providers/azure at v0.0.0-20261007133317-58c25f04c49b,
which contains both fixes, and pkg at the version the azure module
requires. The aws and gcp pins stay where they are so no module moves
backwards.

Error-handling audit for the #207 semantics change (GetExistingCommitments
for Azure savings plans can now fail instead of returning a silently
partial list): every CLI call to GetExistingCommitments goes through
provider.ServiceClient instances built by createServiceClient, which
dispatches AWS services only, so no CLI path calls the Azure inventory
today. The duplicate-purchase guards that do consume the interface
(checkDuplicates, checkDuplicatesForCSVRegion) already fail closed on
error for purchase runs.

Coordinated with #2121: that issue's broader deps adoption should build on
this branch or exclude the azure pin.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant