Skip to content

fix(plans): include universal plans in Account filter (refs #705) - #739

Closed
cristim wants to merge 1 commit into
feat/multicloud-web-frontendfrom
fix/705-plans-filter-v2
Closed

cristim wants to merge 1 commit into
feat/multicloud-web-frontendfrom
fix/705-plans-filter-v2

Conversation

@cristim

@cristim cristim commented May 26, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #705 follow-up (PR #715 made Account filter functional but verification on QA rows 325-326 still failed: plans with no target_account were excluded entirely).

Root cause: buildListPlansQuery in internal/config/store_postgres.go used INNER JOIN plan_accounts ... WHERE pa.account_id IN (...). Plans with no rows in plan_accounts (universal plans created by leaving Target Account blank) were silently excluded because the INNER JOIN matched nothing.

Fix

Replaced the JOIN with WHERE pp.id IN (UNION of two subqueries):

  1. Targeted plans: SELECT pa.plan_id FROM plan_accounts pa WHERE pa.account_id = ANY($1::uuid[]) — unchanged semantics.
  2. Universal plans: plans with no rows in plan_accounts AND whose services JSONB contains at least one service whose provider matches one of the selected accounts' providers. This matches the "Target Account blank = all accounts of this provider" contract from the New Purchase Plan modal.

Per-ID placeholder loop replaced with ANY($1::uuid[]) array argument — consistent with the rest of the store.

Test plan

  • TestPGXMock_ListPurchasePlans_AccountFilter verifies array arg and row return
  • TestPGXMock_ListPurchasePlans_AccountFilterError verifies error propagation
  • Full go test ./...: 4845 pass
  • Manual: select an AWS account in Plans page Global filter — both targeted AWS plans AND universal plans containing AWS services should appear

Summary by CodeRabbit

  • New Features

    • Improved purchase plan filtering to return both explicitly targeted plans and universal plans that match account providers, enhancing visibility of available options.
  • Tests

    • Added comprehensive test coverage for account-filtered purchase plan queries, including error handling.

Review Change Stack

The Account filter on the Plans page excluded plans whose Target Account
field was left blank (NULL plan_accounts entries), even though an empty
Target Account means "all accounts of this provider".

Root cause: buildListPlansQuery used an INNER JOIN on plan_accounts, so
plans with no rows in that join table were silently dropped.

Fix: replace the JOIN with a WHERE pp.id IN (...) that unions two
sub-queries:
  1. Targeted plans: explicitly linked to the given account(s) via
     plan_accounts (unchanged behaviour).
  2. Universal plans: no plan_accounts rows at all, but whose services
     JSONB contains at least one service whose "provider" field matches
     the provider of any of the given cloud accounts.

The accountIDs slice is now passed as a single $1 Postgres array
argument (ANY($1::uuid[])), removing the per-ID placeholder loop and
keeping the query shape static.
@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/low Minor harm urgency/this-quarter Within the quarter impact/few Limited audience labels May 26, 2026
@coderabbitai

coderabbitai Bot commented May 26, 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: 4d39f645-666a-49bc-b901-6b0a914acf99

📥 Commits

Reviewing files that changed from the base of the PR and between 6fcf6a6 and f903ff7.

📒 Files selected for processing (3)
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go

📝 Walkthrough

Walkthrough

This PR extends purchase-plan filtering to return both explicitly targeted plans (referenced via plan_accounts join table) and universal plans (with no target accounts but whose service provider matches the queried account IDs), using a single uuid[] array parameter to optimize query binding across both sub-queries combined via UNION.

Changes

Account-based purchase plan filtering

Layer / File(s) Summary
Filter query logic and documentation
internal/config/store_postgres.go, internal/config/types.go
buildListPlansQuery refactored to construct a UNION of targeted plans (via plan_accounts join) and universal plans (no target accounts, provider match from purchase_plans.services), sharing a single uuid[] bind. Doc comments for buildListPlansQuery, ListPurchasePlans, and PurchasePlanFilter updated to document the two-category semantics.
Account filter test coverage
internal/config/store_postgres_pgxmock_test.go
Added TestPGXMock_ListPurchasePlans_AccountFilter and TestPGXMock_ListPurchasePlans_AccountFilterError to validate query parameterization with array binds and error propagation when the database query fails.

Sequence Diagram

sequenceDiagram
  participant Client
  participant ListPurchasePlans
  participant buildListPlansQuery
  participant Postgres
  Client->>ListPurchasePlans: ListPurchasePlans(AccountIDs=[...])
  ListPurchasePlans->>buildListPlansQuery: Build filtered query
  buildListPlansQuery->>Postgres: SELECT (targeted via plan_accounts UNION universal via services) WHERE uuid[] ANY
  Postgres->>Postgres: Targeted: plan_accounts JOIN plan IDs
  Postgres->>Postgres: Universal: no plan_accounts + provider match
  Postgres-->>ListPurchasePlans: Combined result set
  ListPurchasePlans-->>Client: Purchase plans
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

severity/medium, urgency/this-sprint, impact/many, effort/m, type/bug

Poem

🐰 A filter once lost in the depths of the dark,
Now shines with two paths—let me leave you this mark:
Targeted and universal, together they race,
One UUID to bind them, one place they embrace! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main fix: including universal plans in the Account filter, directly addressing the referenced issue #705.
Linked Issues check ✅ Passed The PR fully implements the coding requirements from #705: backend filtering via account_ids parameter with proper plan_accounts join semantics.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the plan filtering implementation: query logic, tests, and documentation updates with no extraneous modifications.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

✏️ 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/705-plans-filter-v2

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

@cristim

cristim commented May 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 commented May 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 commented May 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 commented May 27, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 27, 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 closed this May 27, 2026
cristim added a commit that referenced this pull request May 27, 2026
…lans.sql

Two minor fixes per CR #743 review:

- F3: header comment referenced PR #739 (the now-closed account-filter
  fix) but the operator-driven cleanup is tracked by issue #742, not
  #739. Update the reference so future readers find the right issue.
- F4: the fan-out cleanup example (option b) attached every cloud
  account matching the plan's provider, including disabled accounts.
  Add `ca.enabled = true` to the JOIN so disabled accounts aren't
  swept into a re-attach pass.
cristim added a commit that referenced this pull request May 27, 2026
…eation (#743)

* feat(api/plans): require non-empty target_accounts on plan creation

Eliminates the "universal plans" class — purchase_plans rows with no
matching plan_accounts row — that the historical "leave Target Account
blank to mean all-accounts-of-this-provider" UX created. Plans now must
be tied to at least one cloud_account.

Changes:
- PlanRequest gains target_accounts ([]string), validated non-empty + per-
  entry UUID-formed at POST /api/plans. Rejected requests return 400
  with a clear message; the store is never touched.
- createPlan inserts the plan_accounts rows immediately after
  CreatePurchasePlan inside the same handler, rolling the plan row back
  if provider-match validation or the SetPlanAccounts write fails. This
  keeps the invariant "every purchase_plans row has at least one
  plan_accounts row" end-to-end so a fresh universal plan can no longer
  appear via a partial failure.
- PUT /api/plans/:id/accounts now rejects an empty account_ids body.
  Closes the back-door for re-creating a universal plan by clearing the
  list on an existing plan.
- OpenAPI schema marks target_accounts as required with minItems: 1.

Tests:
- TestHandler_createPlan_RejectsEmptyTargetAccounts (missing field +
  empty array): both return 400; CreatePurchasePlan never reached.
- TestHandler_createPlan_RejectsInvalidTargetAccountUUID: malformed UUID
  rejected before any DB write.
- TestSetPlanAccounts_RejectsEmpty: PUT-with-empty-list returns 400,
  SetPlanAccounts never reached.
- Existing TestHandler_createPlan + TestHandler_HandleRequest_CreatePlan
  extended to include target_accounts + GetCloudAccount stub for the new
  provider-match path.
- Two router dispatch tests that used empty account_ids switched to a
  single valid UUID — they were never asserting on body content.

Existing universal plans in the DB are NOT touched by this PR — those
require operator review (delete / fan-out / manual reassignment). See
scripts/list_universal_plans.sql in a follow-up commit for the
diagnostic.

* fix(frontend/plans): require Target Account in New Purchase Plan modal

Matches the new backend contract (POST /api/plans rejects empty
target_accounts). Without this change the user could fill in every other
plan field and only see the failure as a toast after the POST round-trip
landed.

Changes:
- Modal help text + section heading mark Target Accounts as required
  (was "Leave empty to target all enabled accounts" — that semantics no
  longer exists).
- savePlan rejects an empty plan-account-ids field with a clear toast
  before the createPlan/updatePlan call ever fires.
- Selected account IDs are stamped onto the request as target_accounts
  so the backend can validate + persist atomically on POST. The
  follow-up PUT /api/plans/:id/accounts call is preserved as defence-in-
  depth for the update flow (which still uses the 2-step write).
- New refreshPlanSaveButtonState() disables the Save Plan submit button
  whenever the chip list is empty and re-enables it as soon as the user
  adds one. The button title surfaces the reason to mouseover.
- CreatePlanRequest.target_accounts added to the API types so the
  request shape is checked at compile time.

Tests:
- "rejects submit and never calls createPlan when Target Accounts is
  empty (universal-plans fix)": empty hidden field → no createPlan
  call, no setPlanAccounts call, toast surfaces the requirement.
- "forwards selected target_accounts on createPlan (universal-plans
  fix)": multi-account chip list flows through to the request body.
- DOM setup gained #plan-accounts-selected, #plan-account-ids, and a
  submit button so the new code paths have something to drive.
- Existing tests in the savePlan describe block now stamp a single UUID
  in beforeEach; tests that re-open the modal (form.reset() clears the
  field) call a stampAccountIds() helper to re-stamp before submit.

* chore(scripts): add list_universal_plans.sql for operator-driven cleanup

Read-only diagnostic for existing universal plans (purchase_plans rows
with no matching plan_accounts row). The companion API change rejects
new ones, but pre-existing rows from the legacy "leave Target Account
blank" UX remain until an operator decides per-plan.

The file documents three cleanup options inline so the operator can pick
without leaving psql:

  (a) DELETE the plan if genuinely orphaned (no consumer, no executions)
  (b) Fan out: insert plan_accounts rows for every enabled cloud_account
      whose provider matches the plan's services map
  (c) Manual review per plan via the (now-required-Target-Account) UI

No destructive operations live in the script — the operator runs the
SELECT, decides per plan, and executes one of the three documented
follow-ups by hand. A separate ops-tracking issue captures the broader
cleanup plan + scheduling.

* fix(api/plans): log rollback-delete failures during create-plan transaction

When validatePlanAccountProviders or SetPlanAccounts fails after a
successful CreatePurchasePlan, the handler rolls the plan row back to
preserve the "every purchase_plans row has at least one plan_accounts
row" invariant. The rollback DeletePurchasePlan error was previously
discarded, so a transient DB failure could leave a partial plan row
behind unnoticed — the exact universal-plan state this PR is trying to
eliminate.

Wrap the rollback delete in a closure that logs at WARN with the plan
ID when it fails, so an operator can clean up manually. The caller
still receives the original cause unchanged (no API contract change).

Adds a regression test asserting that:
- DeletePurchasePlan is called on the rollback path
- The user-facing error is the original SetPlanAccounts error wrapped
  as "accounts: ..." (not the rollback error)
- The rollback error message does not leak into the returned error

CR #743 finding F1.

* fix(api/openapi): scope target_accounts requirement to create-plan only

PlanRequest is reused across POST /api/plans, PUT /api/plans/{id}, and
PATCH /api/plans/{id}. Adding target_accounts to the shared schema's
required array forced the field on every update payload too, which
contradicts updatePlan's design (omitting target_accounts on PUT/PATCH
means "leave the existing account list unchanged"; the back-door is
closed at setPlanAccounts itself by rejecting empty arrays).

Move the requirement onto the POST operation only via an inline allOf
that composes PlanRequest with `required: [target_accounts]`. The
shared schema goes back to `required: [name]`, leaving update payloads
unaffected while still advertising the create contract correctly to
client codegen and OpenAPI validators.

CR #743 finding F2.

* docs(scripts): fix issue ref + add enabled filter in list_universal_plans.sql

Two minor fixes per CR #743 review:

- F3: header comment referenced PR #739 (the now-closed account-filter
  fix) but the operator-driven cleanup is tracked by issue #742, not
  #739. Update the reference so future readers find the right issue.
- F4: the fan-out cleanup example (option b) attached every cloud
  account matching the plan's provider, including disabled accounts.
  Add `ca.enabled = true` to the JOIN so disabled accounts aren't
  swept into a re-attach pass.
@cristim
cristim deleted the fix/705-plans-filter-v2 branch June 3, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

impact/few Limited audience priority/p2 Backlog-worthy severity/low Minor harm triaged Item has been triaged urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant