diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index d2977d50..f7a8a0d9 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -7,7 +7,9 @@ defmodule Towerops.Devices do alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Devices.Event + alias Towerops.Organizations.SubscriptionLimits alias Towerops.Repo + alias Towerops.Sites alias Towerops.Workers.DeviceMonitorWorker alias Towerops.Workers.DevicePollerWorker alias Towerops.Workers.DiscoveryWorker @@ -260,27 +262,69 @@ defmodule Towerops.Devices do @doc """ Creates device. + + ## Options + * `:bypass_limits` - When true, bypasses subscription device limits (for superusers) """ - def create_device(attrs) do - case %DeviceSchema{} - |> DeviceSchema.changeset(attrs) - |> Repo.insert() do - {:ok, device} = result -> - # Start monitoring/polling if enabled - _ = - if device.monitoring_enabled do - DeviceMonitorWorker.start_monitoring(device.id) - end + def create_device(attrs, opts \\ []) do + bypass_limits = Keyword.get(opts, :bypass_limits, false) - _ = - if device.snmp_enabled do - DevicePollerWorker.start_polling(device.id) - end + # Check device quota before insertion (unless bypassing) + with {:ok, changeset} <- check_device_quota(attrs, bypass_limits), + {:ok, device} <- Repo.insert(changeset) do + # Start monitoring/polling if enabled + _ = + if device.monitoring_enabled do + DeviceMonitorWorker.start_monitoring(device.id) + end - result + _ = + if device.snmp_enabled do + DevicePollerWorker.start_polling(device.id) + end - error -> - error + {:ok, device} + end + end + + defp check_device_quota(attrs, bypass_limits) do + changeset = DeviceSchema.changeset(%DeviceSchema{}, attrs) + + if bypass_limits do + {:ok, changeset} + else + do_check_quota(changeset) + end + end + + defp do_check_quota(changeset) do + case Ecto.Changeset.fetch_change(changeset, :site_id) do + {:ok, site_id} -> + validate_device_quota(changeset, site_id) + + :error -> + # No site_id in changeset, let normal validation handle it + {:ok, changeset} + end + end + + defp validate_device_quota(changeset, site_id) do + site = site_id |> Sites.get_site!() |> Repo.preload(:organization) + organization = site.organization + + case SubscriptionLimits.check_device_limit(organization) do + {:ok, :within_limit} -> + {:ok, changeset} + + {:error, :at_limit, _current, max} -> + error_changeset = + Ecto.Changeset.add_error( + changeset, + :base, + "You've reached your plan limit of #{max} devices. Upgrade to add more." + ) + + {:error, error_changeset} end end diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index 42855f9c..865639cb 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -11,6 +11,7 @@ defmodule Towerops.Organizations do alias Towerops.Organizations.Membership alias Towerops.Organizations.Organization alias Towerops.Organizations.Policy + alias Towerops.Organizations.SubscriptionLimits alias Towerops.Repo ## Organizations @@ -54,9 +55,43 @@ defmodule Towerops.Organizations do @doc """ Creates an organization and adds the creator as owner. + + ## Options + * `:bypass_limits` - When true, bypasses free organization limit (for superusers) """ @dialyzer {:nowarn_function, create_organization: 2} - def create_organization(attrs, user_id) do + @dialyzer {:nowarn_function, create_organization: 3} + def create_organization(attrs, user_id, opts \\ []) do + bypass_limits = Keyword.get(opts, :bypass_limits, false) + subscription_plan = Map.get(attrs, :subscription_plan) || Map.get(attrs, "subscription_plan") || "free" + + # Check free org limit before creating (unless bypassing or creating non-free org) + with :ok <- check_free_org_limit(bypass_limits, subscription_plan, user_id, attrs) do + do_create_organization(attrs, user_id) + end + end + + defp check_free_org_limit(bypass_limits, subscription_plan, user_id, attrs) do + if bypass_limits or subscription_plan != "free" do + :ok + else + if SubscriptionLimits.can_create_free_organization?(user_id) do + :ok + else + changeset = + %Organization{} + |> Organization.changeset(attrs) + |> Ecto.Changeset.add_error( + :base, + "You already have a free organization. Upgrade your existing organization to create additional ones." + ) + + {:error, changeset} + end + end + end + + defp do_create_organization(attrs, user_id) do multi = Ecto.Multi.new() multi = Ecto.Multi.insert(multi, :organization, Organization.changeset(%Organization{}, attrs)) diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex index d33277cb..66576404 100644 --- a/lib/towerops/organizations/organization.ex +++ b/lib/towerops/organizations/organization.ex @@ -23,6 +23,7 @@ defmodule Towerops.Organizations.Organization do schema "organizations" do field :name, :string field :slug, :string + field :subscription_plan, :string, default: "free" # device) field :snmp_version, :string, default: "2c" @@ -41,6 +42,7 @@ defmodule Towerops.Organizations.Organization do id: Ecto.UUID.t(), name: String.t(), slug: String.t(), + subscription_plan: String.t(), snmp_version: String.t() | nil, snmp_community: String.t() | nil, default_agent_token_id: Ecto.UUID.t() | nil, @@ -55,9 +57,16 @@ defmodule Towerops.Organizations.Organization do @doc false def changeset(organization, attrs) do organization - |> cast(attrs, [:name, :default_agent_token_id, :snmp_version, :snmp_community]) + |> cast(attrs, [ + :name, + :subscription_plan, + :default_agent_token_id, + :snmp_version, + :snmp_community + ]) |> validate_required([:name]) |> validate_length(:name, min: 2, max: 100) + |> validate_inclusion(:subscription_plan, ["free"]) |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> generate_slug() |> validate_required([:slug]) diff --git a/lib/towerops/organizations/subscription_limits.ex b/lib/towerops/organizations/subscription_limits.ex new file mode 100644 index 00000000..2b84c255 --- /dev/null +++ b/lib/towerops/organizations/subscription_limits.ex @@ -0,0 +1,143 @@ +defmodule Towerops.Organizations.SubscriptionLimits do + @moduledoc """ + Subscription plan limits and quota enforcement. + + Currently supports: + - Free plan: 10 devices max, 1 owned organization per user + - Future plans can be added here (per-device pricing, etc.) + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Organizations.Membership + alias Towerops.Organizations.Organization + alias Towerops.Repo + + @free_device_limit 10 + + @doc """ + Returns the device limit for a given subscription plan. + + ## Examples + + iex> device_limit("free") + 10 + + iex> device_limit("paid") + :unlimited + """ + def device_limit("free"), do: @free_device_limit + def device_limit(_other), do: :unlimited + + @doc """ + Checks if an organization is within its device quota. + + Returns `{:ok, :within_limit}` if the organization can add more devices. + Returns `{:error, :at_limit, current, max}` if at or over the limit. + + ## Examples + + iex> check_device_limit(%Organization{subscription_plan: "free"}) + {:ok, :within_limit} + + iex> check_device_limit(%Organization{subscription_plan: "free"}) + {:error, :at_limit, 10, 10} + """ + def check_device_limit(%Organization{} = organization) do + case device_limit(organization.subscription_plan) do + :unlimited -> + {:ok, :within_limit} + + max_devices -> + current = count_organization_devices(organization.id) + + if current >= max_devices do + {:error, :at_limit, current, max_devices} + else + {:ok, :within_limit} + end + end + end + + @doc """ + Returns the current device count and limit for an organization. + + Returns `{current_count, limit}` where limit is either an integer or `:unlimited`. + + ## Examples + + iex> device_quota(%Organization{id: "...", subscription_plan: "free"}) + {5, 10} + + iex> device_quota(%Organization{id: "...", subscription_plan: "paid"}) + {45, :unlimited} + """ + def device_quota(%Organization{} = organization) do + current = count_organization_devices(organization.id) + limit = device_limit(organization.subscription_plan) + {current, limit} + end + + @doc """ + Checks if a user can create a free organization. + + Users are limited to owning 1 free organization. They can create unlimited + paid organizations or be invited as members to unlimited organizations. + + Returns `true` if the user can create another free organization, `false` otherwise. + + ## Examples + + iex> can_create_free_organization?(user_id) + true + + iex> can_create_free_organization?(user_id) + false + """ + def can_create_free_organization?(user_id) do + count_user_owned_free_organizations(user_id) < 1 + end + + @doc """ + Counts how many free organizations a user owns. + + Only counts organizations where the user has the `:owner` role. + Memberships as admin/member/viewer do not count. + + ## Examples + + iex> count_user_owned_free_organizations(user_id) + 1 + + iex> count_user_owned_free_organizations(user_id) + 0 + """ + def count_user_owned_free_organizations(user_id) do + Repo.aggregate( + from(o in Organization, + join: m in Membership, + on: m.organization_id == o.id, + where: m.user_id == ^user_id, + where: m.role == :owner, + where: o.subscription_plan == "free" + ), + :count + ) + end + + @doc """ + Counts total devices for an organization. + + Counts devices across all sites in the organization. + """ + def count_organization_devices(organization_id) do + Repo.aggregate( + from(d in Device, + join: s in assoc(d, :site), + where: s.organization_id == ^organization_id + ), + :count + ) + end +end diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index 1c13b82d..1708f6a7 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -5,6 +5,7 @@ defmodule ToweropsWeb.DeviceLive.Form do alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.Device, as: DeviceSchema + alias Towerops.Organizations.SubscriptionLimits alias Towerops.Sites alias Towerops.Snmp alias Towerops.Workers.DiscoveryWorker @@ -19,6 +20,16 @@ defmodule ToweropsWeb.DeviceLive.Form do cloud_pollers = Agents.list_cloud_pollers() all_agents = agents ++ cloud_pollers + # Load device quota + {current_devices, device_limit} = + SubscriptionLimits.device_quota(organization) + + is_at_limit = + case device_limit do + :unlimited -> false + limit -> current_devices >= limit + end + # Redirect to sites page if no sites exist if Enum.empty?(sites) do {:ok, @@ -34,7 +45,12 @@ defmodule ToweropsWeb.DeviceLive.Form do |> assign(:preselected_site_id, params["site_id"]) |> assign(:snmp_test_result, nil) |> assign(:duplicate_device, nil) - |> assign(:non_routable_ip_error, false)} + |> assign(:non_routable_ip_error, false) + |> assign(:device_quota, %{ + current: current_devices, + limit: device_limit, + at_limit: is_at_limit + })} end end @@ -251,13 +267,27 @@ defmodule ToweropsWeb.DeviceLive.Form do snmp_enabled = socket.assigns.monitoring_mode == "snmp_and_icmp" device_params = Map.put(device_params, "snmp_enabled", snmp_enabled) - case Devices.create_device(device_params) do + # Check if user is superuser and bypass limits if so + is_superuser = socket.assigns.current_scope.user.is_superuser + opts = if is_superuser, do: [bypass_limits: true], else: [] + + case Devices.create_device(device_params, opts) do {:ok, device} -> # Handle agent assignment after device creation handle_agent_assignment(device.id, agent_token_id) flash_message = handle_device_creation(device) + # Reload quota after device creation + organization = socket.assigns.organization + {current, limit} = SubscriptionLimits.device_quota(organization) + + is_at_limit = + case limit do + :unlimited -> false + l -> current >= l + end + # Reset form for next device while staying on the add page fresh_attrs = %{ monitoring_enabled: true, @@ -275,6 +305,7 @@ defmodule ToweropsWeb.DeviceLive.Form do socket |> put_flash(:info, flash_message) |> assign(:form, to_form(fresh_changeset)) + |> assign(:device_quota, %{current: current, limit: limit, at_limit: is_at_limit}) |> assign(:snmp_test_result, nil) |> push_event("scroll_to_top", %{})} diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex index a3ae1904..fa31a946 100644 --- a/lib/towerops_web/live/device_live/form.html.heex +++ b/lib/towerops_web/live/device_live/form.html.heex @@ -19,14 +19,79 @@ - <.header> - {@page_title} - <:subtitle> - {if @live_action == :new, - do: "Add new device to monitor", - else: "Update device details"} - - +
+ You have {remaining} {if remaining == 1, do: "slot", else: "slots"} remaining. +
++ You already have {@free_org_count} free {if @free_org_count == 1, + do: "organization", + else: "organizations"}. + To create additional organizations, you'll need to upgrade your existing organization to a paid plan. +
+