Skip to content

Conversation

@fm3
Copy link
Member

@fm3 fm3 commented Dec 4, 2025

Superusers can now edit organization pricing plans via API.

Admins and superusers can list organization pricing plan updates.

Example: curl 'http://localhost:9000/api/pricing/updatePlan' -X 'POST' --data '{"organizationId":"sample_organization","description":"a description!","paidUntil":"2025-12-12T00:00:00Z","includedUsers": null}' -H 'X-Auth-Token: secretSampleUserToken' --header "Content-Type: application/json"

Note that for the fields paidUntil, includedUsers and includedStorageBytes the value null in the json means that the value is updated to null (which means unlimited). If the key is absent from the json, the value is unchanged. Description and pricingPlan can also be omitted, meaning unchanged.

in the update list route GET http://localhost:9000/api/pricing/planUpdates, the same differentiation between null and absent applies for the response body.

URL of deployed dev instance (used for testing):

Steps to test:

  • send update requests as mentioned above
  • organization values should be updated
  • update list route should return the updates in order of creation

Issues:


  • Added changelog entry (create a $PR_NUMBER.md file in unreleased_changes or use ./tools/create-changelog-entry.py)
  • Added migration guide entry if applicable (edit the same file as for the changelog)
  • Removed dev-only changes like prints and application.conf edits
  • Considered common edge cases

@fm3 fm3 self-assigned this Dec 4, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 4, 2025

📝 Walkthrough

Walkthrough

This PR adds infrastructure for tracking organization pricing plan updates. It introduces a new OrganizationPlanUpdate data class, two API endpoints for updating plans and listing plan history, database methods to persist and retrieve updates, a new SQL table organization_plan_updates with change-tracking flags, and a helper method to validate superuser status in UserService.

Changes

Cohort / File(s) Summary
OrganizationPlanUpdate Type Definition
app/controllers/OrganizationController.scala
Added OrganizationPlanUpdate case class with fields for organizationId, description, pricingPlan, paidUntil, includedUsers, includedStorageBytes, created; companion object with JSON format using tristateOptionParsing; imported Instant, TristateOptionJsonHelper, and PricingPlan types.
API Endpoints
app/controllers/OrganizationController.scala
Added updatePlan() action (requires superuser, validates and persists plan updates) and listPlanUpdates action (restricted to admins/superusers, returns plan update history as JSON).
DAO Plan Update Methods
app/models/organization/Organization.scala
Added updatePlan() and insertPlanUpdate() methods with conditional field updates based on flags; added findPlanUpdates() to retrieve plan update history; added private parsePlanUpdate() helper to convert database rows to OrganizationPlanUpdate objects.
Superuser Helper
app/models/user/UserService.scala
Added public isSuperUser() method returning Fox[Boolean]; refactored assertIsSuperUser() to delegate to new helper.
Route Registration
conf/webknossos.latest.routes
Added routes: POST /pricing/updatePlan and GET /pricing/planUpdates mapped to OrganizationController actions.
Database Schema
schema/evolutions/146-organization-plan-updates.sql, schema/evolutions/reversions/146-organization-plan-updates.sql, schema/schema.sql
Created new organization_plan_updates table with columns for organization, description, pricingPlan, paidUntil (with change flag), includedUsers (with change flag), includedStorage (with change flag), and creation timestamp; added foreign key to organizations table with cascading delete; updated schema version from 145 to 146.
Release Notes
unreleased_changes/9129.md
Documented new feature enabling superusers to edit organization pricing plans via API.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Conditional update logic in updatePlan() method requires verification that all fields are properly gated by their change flags
  • JSON parsing with tristateOptionParsing pattern needs validation across the data class and DAO layer
  • SQL schema constraints and foreign key relationships should be checked for consistency with application logic
  • Superuser authorization checks must be verified on both endpoints

Possibly related PRs

  • Rename Basic to Personal plan #8951: Renames PricingPlan enum value (Basic → Personal), affecting the same enum used in the new OrganizationPlanUpdate type and its database parsing logic.

Suggested labels

backend

Poem

🐰 A table springs up in the database deep,
Where pricing plans are tracked and will keep,
Each change that's made by superuser hand,
Recorded now throughout the land!
With flags and timestamps, history's clear,
Organization plans, crystal dear!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements a route to record plan changes and logs them to a Postgres table. However, it does not record old values, new values separately, the issuing superuser, or support optional comments as required by issue #9120. Implement storage of old and new values for changed fields, record the superuser ID making the change, and add support for optional comment field in the OrganizationPlanUpdate structure and database schema.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Route + log for organization plan updates' directly describes the main changes: adding a new API route and logging functionality for organization plan updates.
Out of Scope Changes check ✅ Passed All changes are in-scope: new controller endpoints and DAO methods for plan updates, database schema evolution for the organization_plan_updates table, and UserService helper method for superuser checks required by the update functionality.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description clearly relates to the changeset, describing new API endpoints for managing organization pricing plans with specific examples and field semantics.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch organization-plan-updates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@fm3 fm3 changed the title WIP Route + log for organization plan updates Route + log for organization plan updates Dec 4, 2025
@fm3 fm3 marked this pull request as ready for review December 4, 2025 13:07
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
schema/schema.sql (1)

359-371: Consider adding a primary key and index for query performance.

The organization_plan_updates table lacks a primary key. While it may function as an append-only log, having a primary key is a best practice for data integrity and potential future references. Additionally, consider adding an index on _organization for efficient lookups when listing plan updates.

 CREATE TABLE webknossos.organization_plan_updates(
+  _id TEXT CONSTRAINT _id_objectId CHECK (_id ~ '^[0-9a-f]{24}$') PRIMARY KEY DEFAULT webknossos.generate_object_id(),
   _organization TEXT NOT NULL,
   description TEXT DEFAULT NULL,
   pricingPlan webknossos.PRICING_PLANS DEFAULT NULL,
   paidUntil TIMESTAMPTZ DEFAULT NULL,
   paidUntilChanged BOOLEAN NOT NULL, -- bool is necessary because set to null is distinct from did not change
   includedUsers INTEGER DEFAULT NULL,
   includedUsersChanged BOOLEAN NOT NULL, -- bool is necessary because set to null is distinct from did not change
   includedStorage BIGINT DEFAULT NULL,
   includedStorageChanged BOOLEAN NOT NULL, -- bool is necessary because set to null is distinct from did not change
   created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
   CONSTRAINT validOrganizationId CHECK (_organization ~* '^[A-Za-z0-9\-_. ]+$')
 );

Add an index after the table definition:

CREATE INDEX ON webknossos.organization_plan_updates(_organization);
app/controllers/OrganizationController.scala (1)

319-329: Consider adding logging for audit purposes.

Given this is a sensitive operation (modifying pricing plans), consider adding a log statement similar to other privileged operations in this codebase (e.g., delete action at line 191).

       _ <- userService.assertIsSuperUser(request.identity)
       organization <- organizationDAO.findOne(request.body.organizationId) ?~> Messages(
         "organization.notFound",
         request.body.organizationId) ~> NOT_FOUND
+      _ = logger.info(s"Superuser ${request.identity._id} updating plan for organization ${organization._id}")
       _ <- organizationDAO.insertPlanUpdate(organization._id, request.body)
       _ <- organizationDAO.updatePlan(organization._id, request.body)
     } yield Ok
app/models/organization/Organization.scala (1)

13-13: Avoid DAO depending on controllers.OrganizationPlanUpdate

Pulling a controller‑package type into the DAO inverts the usual layering (models/DAO should not depend on controllers). Consider moving OrganizationPlanUpdate into a shared models or dto package and having both controller and DAO depend on that instead.

schema/evolutions/146-organization-plan-updates.sql (1)

5-21: Consider indexing for findPlanUpdates query

findPlanUpdates queries:

  • WHERE _organization = ?
  • ORDER BY created

On a table that may grow over time this can degrade to a full scan + sort. It would be worth adding an index such as:

CREATE INDEX organization_plan_updates_org_created_idx
  ON webknossos.organization_plan_updates (_organization, created);

to keep the listing endpoint responsive as more updates accumulate.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 84df092 and c052a7a.

📒 Files selected for processing (8)
  • app/controllers/OrganizationController.scala (3 hunks)
  • app/models/organization/Organization.scala (2 hunks)
  • app/models/user/UserService.scala (1 hunks)
  • conf/webknossos.latest.routes (1 hunks)
  • schema/evolutions/146-organization-plan-updates.sql (1 hunks)
  • schema/evolutions/reversions/146-organization-plan-updates.sql (1 hunks)
  • schema/schema.sql (3 hunks)
  • unreleased_changes/9129.md (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
app/controllers/OrganizationController.scala (2)
util/src/main/scala/com/scalableminds/util/time/Instant.scala (3)
  • Instant (14-45)
  • Instant (47-103)
  • now (48-48)
app/models/team/PricingPlan.scala (1)
  • PricingPlan (5-10)
app/models/user/UserService.scala (2)
util/src/main/scala/com/scalableminds/util/tools/Fox.scala (1)
  • assertTrue (192-198)
app/models/analytics/AnalyticsService.scala (1)
  • isSuperUser (103-108)
🔇 Additional comments (9)
app/models/user/UserService.scala (1)

86-90: LGTM! Clean extraction of the super-user check logic.

This refactor follows the same pattern used in AnalyticsService.scala and enables reuse of the isSuperUser check in the new listPlanUpdates endpoint without duplicating DAO calls.

schema/evolutions/reversions/146-organization-plan-updates.sql (1)

1-9: LGTM! Standard reversion script following established patterns.

The script correctly validates the schema version, drops the new table, and reverts the version number within a transaction.

unreleased_changes/9129.md (1)

1-5: LGTM! Changelog entry is clear and complete.

conf/webknossos.latest.routes (1)

258-259: LGTM! Routes are well-placed and follow existing naming conventions.

The new endpoints are logically grouped with other pricing-related routes.

schema/schema.sql (2)

24-24: Schema version increment is correct.


954-955: Foreign key constraint is correctly defined with ON DELETE CASCADE.

app/controllers/OrganizationController.scala (2)

331-338: Verify intended scope for superuser access.

Currently, listPlanUpdates only returns updates for the requesting user's current organization. Since superusers can update any organization's plan (via updatePlan), should they also be able to list updates for any organization (perhaps via an optional organizationId query parameter)?

If the current behavior is intentional (superusers see only their org's updates unless they switch), this is fine. Otherwise, consider:

-  def listPlanUpdates: Action[AnyContent] =
+  def listPlanUpdates(organizationId: Option[String]): Action[AnyContent] =
     sil.SecuredAction.async { implicit request =>
       for {
         isSuperUser <- userService.isSuperUser(request.identity._multiUser)
         _ <- Fox.fromBool(isSuperUser || request.identity.isAdmin) ?~> "organization.listPlanUpdates.onlyAdmin"
-        planUpdates <- organizationDAO.findPlanUpdates(request.identity._organization)
+        targetOrgId = if (isSuperUser) organizationId.getOrElse(request.identity._organization) 
+                      else request.identity._organization
+        _ <- Fox.fromBool(!organizationId.isDefined || isSuperUser) ?~> "notAllowed" ~> FORBIDDEN
+        planUpdates <- organizationDAO.findPlanUpdates(targetOrgId)
       } yield Ok(Json.toJson(planUpdates))
     }

43-46: TristateOptionJsonHelper correctly handles tristate Option parsing.

The tristateOptionParsing configuration properly differentiates between absent JSON keys (produces None) and explicit null values (produces Some(None)). The case class default values of Some(None) for fields like paidUntil, includedUsers, and includedStorageBytes are only used for programmatic construction; JSON deserialization is controlled entirely by the InvertedDefaultHandler logic, which treats absent keys and null values distinctly.

app/models/organization/Organization.scala (1)

346-375: Plan update parsing and change‑flag semantics look consistent

parsePlanUpdate correctly reconstructs the “changed vs unchanged vs set‑to‑null” semantics:

  • paidUntil = Some(Some(ts)) → changed to concrete date.
  • paidUntil = Some(None) → changed to unlimited (NULL).
  • paidUntil = None → unchanged (per paidUntilChanged flag).

The same pattern for includedUsers/includedStorage matches the described API behavior, and findPlanUpdates selects all columns in the order defined in the evolution, so mapping to OrganizationPlanUpdatesRow should be stable.

Comment on lines +5 to +21
CREATE TABLE webknossos.organization_plan_updates(
_organization TEXT NOT NULL,
description TEXT DEFAULT NULL,
pricingPlan webknossos.PRICING_PLANS DEFAULT NULL,
paidUntil TIMESTAMPTZ DEFAULT NULL,
paidUntilChanged BOOLEAN NOT NULL, -- bool is necessary because set to null is distinct from did not change
includedUsers INTEGER DEFAULT NULL,
includedUsersChanged BOOLEAN NOT NULL, -- bool is necessary because set to null is distinct from did not change
includedStorage BIGINT DEFAULT NULL,
includedStorageChanged BOOLEAN NOT NULL, -- bool is necessary because set to null is distinct from did not change
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT validOrganizationId CHECK (_organization ~* '^[A-Za-z0-9\-_. ]+$')
);

ALTER TABLE webknossos.organization_plan_updates
ADD CONSTRAINT organization_ref FOREIGN KEY(_organization) REFERENCES webknossos.organizations(_id) ON DELETE CASCADE DEFERRABLE;

Copy link
Contributor

@coderabbitai coderabbitai bot Dec 4, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find references to organization_plan_updates table in codebase
rg "organization_plan_updates" --type sql --type scala --type java --type ts --type js -A 5 -B 2

Repository: scalableminds/webknossos

Length of output: 5396


🏁 Script executed:

# Search for related issues, PRs, or requirements documentation
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "*.rst" \) | xargs rg "organization_plan_updates\|pricing.*plan.*updates\|audit.*organization" 2>/dev/null || true

Repository: scalableminds/webknossos

Length of output: 50


🏁 Script executed:

# Check for any DAO or SQL query files related to organization plans
fd -e scala -e java -e ts -e js | xargs rg "organization.*plan|PlanUpdate" --type scala --type java --type ts --type js -l 2>/dev/null | head -20

Repository: scalableminds/webknossos

Length of output: 543


🏁 Script executed:

# Look at the git history of this migration file
git log --oneline --all -- "schema/evolutions/146-organization-plan-updates.sql" 2>/dev/null | head -10

Repository: scalableminds/webknossos

Length of output: 129


🏁 Script executed:

# Check if there are any related migrations before or after 146
ls -la schema/evolutions/14[5-7]*.sql 2>/dev/null || ls -la schema/evolutions/ | grep -E "14[5-7]"

Repository: scalableminds/webknossos

Length of output: 264


🏁 Script executed:

# Get more context on the Organization.scala file, especially around insertPlanUpdate and getPlanUpdates
sed -n '1,50p' app/models/organization/Organization.scala

Repository: scalableminds/webknossos

Length of output: 1683


🏁 Script executed:

# Find the full insertPlanUpdate and related methods
rg -A 20 "insertPlanUpdate" app/models/organization/Organization.scala

Repository: scalableminds/webknossos

Length of output: 1117


🏁 Script executed:

# Find any references to "actor" or "changedBy" in organization-related files
rg "actor|changedBy|performedBy|performedAt|changed_by" app/models/organization/ --type scala

Repository: scalableminds/webknossos

Length of output: 337


🏁 Script executed:

# Search for audit-related comments or documentation in the codebase
rg "audit.*log|audit.*table" --type scala --type sql -i

Repository: scalableminds/webknossos

Length of output: 50


🏁 Script executed:

# Check the OrganizationController to see how insertPlanUpdate is used
rg -B 5 -A 10 "insertPlanUpdate" app/controllers/OrganizationController.scala

Repository: scalableminds/webknossos

Length of output: 873


🏁 Script executed:

# Look for any PR or issue references in commit messages related to organization plan updates
git log --all --oneline --grep="plan.*update\|organization.*plan" 2>/dev/null | head -20

Repository: scalableminds/webknossos

Length of output: 566


🏁 Script executed:

# Search for the original PR number or issue
git log --all --format="%H %s" -- schema/evolutions/146-organization-plan-updates.sql | head -5

Repository: scalableminds/webknossos

Length of output: 161


🏁 Script executed:

# Get commit details for the merge commit
git show c052a7af --stat 2>/dev/null | head -50

Repository: scalableminds/webknossos

Length of output: 3019


🏁 Script executed:

# Look for TODOs or FIXMEs related to organization plan updates
rg "TODO|FIXME" app/models/organization/ app/controllers/OrganizationController.scala -B 2 -A 2

Repository: scalableminds/webknossos

Length of output: 50


🏁 Script executed:

# Check the OrganizationPlanUpdate case class to understand the structure
rg -B 5 -A 15 "case class OrganizationPlanUpdate" app/controllers/

Repository: scalableminds/webknossos

Length of output: 2049


🏁 Script executed:

# Search for any documentation or requirements about audit logging
find . -type f \( -name "*.md" -o -name "*.txt" \) | xargs rg -l "plan.*update\|organization.*audit" 2>/dev/null | head -10

Repository: scalableminds/webknossos

Length of output: 50


🏁 Script executed:

# Look at the full listPlanUpdates implementation to see what data is returned
rg -B 3 -A 20 "def listPlanUpdates|def findPlanUpdates" app/models/organization/Organization.scala

Repository: scalableminds/webknossos

Length of output: 708


Missing actor and old values prevent this table from serving as a complete audit log

The table stores only new values plus boolean flags indicating what changed, with no column recording which superuser performed each update or previous values. The findPlanUpdates() query filters by _organization and orders by created without a supporting index.

To make this table self-contained, add:

  • A column for the acting superuser (e.g., _changedBy referencing the multi-user table)
  • Either "old" value columns or a JSONB diff field capturing before/after state for each changed property
  • An index on (_organization, created) to optimize the primary query pattern
🤖 Prompt for AI Agents
In schema/evolutions/146-organization-plan-updates.sql around lines 5 to 21, the
table only records new values and boolean change flags but lacks the acting
superuser and previous-state data and has no index for the primary query
pattern; add a _changedBy column that references the multi-user table (FK to the
appropriate users table), add either explicit "<field>_old" columns
(paidUntil_old, includedUsers_old, includedStorage_old, pricingPlan_old,
description_old) or a single JSONB column (e.g., diff JSONB) to capture
before/after values, and create an index on (_organization, created) to optimize
findPlanUpdates() queries; ensure the new FK is nullable if needed and the
chosen old-values field(s) have appropriate types matching the new-value
columns.

Copy link
Member Author

@fm3 fm3 Dec 4, 2025

Choose a reason for hiding this comment

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

@normanrz do you think we should also store old values, and the changedBy reference? My guess would be that the current implementation is sufficient for your use case?

Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

import models.organization.{FreeCreditTransactionService, OrganizationDAO, OrganizationService}
import models.user.{InviteDAO, MultiUserDAO, UserDAO, UserService}
import models.team.PricingPlan
import models.team.PricingPlan.PricingPlan
Copy link
Member

Choose a reason for hiding this comment

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

hmm?


import scala.concurrent.ExecutionContext

case class OrganizationPlanUpdate(
Copy link
Member

Choose a reason for hiding this comment

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

Should this not go to models?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Route and log for organization plan changes

3 participants