defmodule Towerops.Organizations do @moduledoc """ The Organizations context. """ import Ecto.Query alias Towerops.Agents.AgentAssignment alias Towerops.Devices.Device alias Towerops.Organizations.Invitation alias Towerops.Organizations.Membership alias Towerops.Organizations.Organization alias Towerops.Organizations.Policy alias Towerops.Organizations.SubscriptionLimits alias Towerops.Repo ## Organizations @doc """ Returns the list of organizations for a user. """ def list_user_organizations(user_id) do Repo.all( from(o in Organization, join: m in Membership, on: m.organization_id == o.id, where: m.user_id == ^user_id, order_by: [desc: m.inserted_at], preload: [memberships: m] ) ) end @doc """ Checks if a user has access to an organization. """ def user_has_access?(user_id, organization_id) do Repo.exists?( from m in Membership, where: m.user_id == ^user_id and m.organization_id == ^organization_id ) end @doc """ Gets a single organization by ID. """ def get_organization!(id), do: Repo.get!(Organization, id) @doc """ Gets a single organization by slug. """ def get_organization_by_slug!(slug) do Repo.get_by!(Organization, slug: slug) end @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} @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)) multi = Ecto.Multi.insert(multi, :membership, fn %{organization: organization} -> Membership.changeset(%Membership{}, %{ organization_id: organization.id, user_id: user_id, role: :owner }) end) case Repo.transaction(multi) do {:ok, %{organization: organization}} -> {:ok, organization} {:error, :organization, changeset, _} -> {:error, changeset} {:error, :membership, changeset, _} -> {:error, changeset} end end @doc """ Updates an organization. """ def update_organization(%Organization{} = organization, attrs) do old_community = organization.snmp_community case organization |> Organization.changeset(attrs) |> Repo.update() do {:ok, updated_organization} = result -> # If community string changed, propagate to inheriting devices if updated_organization.snmp_community != old_community do Towerops.Devices.propagate_organization_community_change( updated_organization.id, updated_organization.snmp_community ) end result error -> error end end @doc """ Deletes an organization. """ def delete_organization(%Organization{} = organization) do Repo.delete(organization) end @doc """ Returns an `%Ecto.Changeset{}` for tracking organization changes. """ def change_organization(%Organization{} = organization, attrs \\ %{}) do Organization.changeset(organization, attrs) end ## Memberships @doc """ Gets a user's membership for an organization. """ def get_membership(organization_id, user_id) do Repo.get_by(Membership, organization_id: organization_id, user_id: user_id) end @doc """ Gets a user's membership for an organization, raises if not found. """ def get_membership!(organization_id, user_id) do Repo.get_by!(Membership, organization_id: organization_id, user_id: user_id) end @doc """ Lists all memberships for an organization. """ def list_organization_memberships(organization_id) do Repo.all( from(m in Membership, where: m.organization_id == ^organization_id, preload: [:user], order_by: [asc: m.inserted_at] ) ) end @doc """ Lists users who should receive alert notifications for an organization. Returns owners and admins. """ def list_organization_notification_recipients(organization_id) do Repo.all( from(u in Towerops.Accounts.User, join: m in Membership, on: m.user_id == u.id, where: m.organization_id == ^organization_id, where: m.role in [:owner, :admin], select: u ) ) end @doc """ Creates a membership (adds a user to an organization). """ def create_membership(attrs) do %Membership{} |> Membership.changeset(attrs) |> Repo.insert() end @doc """ Updates a membership (changes a user's role). """ def update_membership(%Membership{} = membership, attrs) do membership |> Membership.changeset(attrs) |> Repo.update() end @doc """ Deletes a membership (removes a user from an organization). """ def delete_membership(%Membership{} = membership) do Repo.delete(membership) end ## Invitations @doc """ Lists pending invitations for an organization. """ def list_pending_invitations(organization_id) do now = DateTime.utc_now() Repo.all( from(i in Invitation, where: i.organization_id == ^organization_id, where: is_nil(i.accepted_at), where: i.expires_at > ^now, preload: [:invited_by], order_by: [desc: i.inserted_at] ) ) end @doc """ Creates an invitation. """ def create_invitation(attrs) do %Invitation{} |> Invitation.changeset(attrs) |> Repo.insert() end @doc """ Gets an invitation by token. """ def get_invitation_by_token(token) do now = DateTime.utc_now() Repo.one( from(i in Invitation, where: i.token == ^token, where: is_nil(i.accepted_at), where: i.expires_at > ^now, preload: [:organization] ) ) end @doc """ Accepts an invitation and creates a membership. """ @dialyzer {:nowarn_function, accept_invitation: 2} def accept_invitation(invitation, user_id) do multi = Ecto.Multi.new() multi = Ecto.Multi.update(multi, :invitation, fn _ -> Ecto.Changeset.change(invitation, %{ accepted_at: DateTime.truncate(DateTime.utc_now(), :second), accepted_by_id: user_id }) end) multi = Ecto.Multi.insert(multi, :membership, fn _ -> Membership.changeset(%Membership{}, %{ organization_id: invitation.organization_id, user_id: user_id, role: invitation.role }) end) case Repo.transaction(multi) do {:ok, %{membership: membership}} -> {:ok, membership} {:error, _failed_operation, changeset, _changes_so_far} -> {:error, changeset} end end @doc """ Deletes an invitation. """ def delete_invitation(%Invitation{} = invitation) do Repo.delete(invitation) end ## Bulk Configuration Updates @doc """ Applies organization's SNMP configuration to all devices in the organization. This updates all devices records to use the organization's SNMP version and community string, overwriting any existing device-level or site-level configurations. Returns the number of device records updated. """ def apply_snmp_config_to_all_equipment(organization_id) do organization = get_organization!(organization_id) Repo.update_all(from(e in Device, join: s in assoc(e, :site), where: s.organization_id == ^organization_id), set: [ snmp_version: organization.snmp_version, snmp_community: organization.snmp_community, updated_at: DateTime.truncate(DateTime.utc_now(), :second) ] ) end @doc """ Applies organization's default agent to all devices in the organization. This creates/updates agent assignments for all devices to use the organization's default agent, overwriting any existing device-level or site-level assignments. Returns the number of device records assigned. """ def apply_agent_to_all_equipment(organization_id) do organization = Repo.get!(Organization, organization_id) if organization.default_agent_token_id do # device IDs for this organization device_ids = Repo.all( from(e in Device, join: s in assoc(e, :site), where: s.organization_id == ^organization_id, select: e.id) ) # Delete existing assignments Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids)) # Create new assignments now = DateTime.truncate(DateTime.utc_now(), :second) assignments = Enum.map(device_ids, fn device_id -> %{ device_id: device_id, agent_token_id: organization.default_agent_token_id, inserted_at: now, updated_at: now } end) {count, _} = Repo.insert_all(AgentAssignment, assignments) {count, nil} else # No default agent set, delete all assignments device_ids = Repo.all( from(e in Device, join: s in assoc(e, :site), where: s.organization_id == ^organization_id, select: e.id) ) Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids)) end end ## Authorization @doc """ Checks if a membership has permission to perform an action on a resource. ## Examples iex> can?(membership, :edit, :device) true iex> can?(membership, :delete, :organization) false """ defdelegate can?(membership, action, resource), to: Policy end