Changes to eliminate @dialyzer suppressions by fixing underlying causes:
NIF stubs (towerops_native.ex, mib_translator.ex):
- Change stubs to :erlang.nif_error(:nif_not_loaded) (no_return type).
Real NIF replaces stubs at load time; calls to unloaded stubs now fail
loudly instead of returning fake data. Lets dialyzer trust @spec.
- Remove @dialyzer :nowarn_function on three NIFs and on translate/1.
Discovery sync_* functions (snmp/discovery.ex, channels/agent_channel.ex):
- agent_channel passes %{device_id: _, interfaces: _} and %{id: _} maps
into Discovery.sync_ip_addresses/sync_processors/sync_storage, which
@spec'd only %Device{}. Add narrow map-type unions (ip_sync_device,
snmp_device_ref) reflecting what the functions actually access.
- Remove @dialyzer :nowarn_function on three agent_channel helpers.
remote_ip.ex — real bug caught and fixed:
- `:ranch.get_addr(socket.transport_pid)` was always raising since
Bandit uses ThousandIsland, not Ranch; the rescue _ -> nil silently
returned nil every time. Switched to Phoenix's documented
:peer_data connect_info (already enabled in endpoint.ex) via
socket.assigns; remote IP now actually works.
- Remove remote_ip.ex entry from .dialyzer_ignore.exs.
Accounts / Organizations (Ecto.Multi opacity):
- Add @specs to Multi-building helpers, refactor into pipe chains.
- 6 @dialyzer :nowarn_function → 0, but 7 :no_opaque remain. Root
cause is upstream: Ecto.Multi.new/0 returns a struct with a literal
%MapSet{} whose @opaque internal representation trips dialyzer on
every subsequent Multi.* call. Unfixable without an Ecto patch or
bypassing Multi entirely. Comments document the specific upstream
issue rather than a vague "Ecto.Multi opacity" claim.
Devices.ex:
- Adding @specs made it worse (call_without_opaque → contract_with_
opaque); inlining the Multi didn't help either — same MapSet root
cause. Suppression kept with a sharper comment.
633 lines
19 KiB
Elixir
633 lines
19 KiB
Elixir
defmodule Towerops.Organizations do
|
|
@moduledoc """
|
|
The Organizations context.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Accounts.User
|
|
alias Towerops.Agents.AgentAssignment
|
|
alias Towerops.Contexts.ConfigChangeTracker
|
|
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
|
|
alias Towerops.Sites.Site
|
|
|
|
## Organizations
|
|
|
|
@doc """
|
|
Returns all organization IDs. Used by EventLogger to subscribe to org-scoped PubSub topics.
|
|
|
|
Note: Uses streaming for memory efficiency even though org count is typically small.
|
|
"""
|
|
@spec list_organization_ids() :: [String.t()]
|
|
def list_organization_ids do
|
|
fn ->
|
|
from(o in Organization, select: o.id)
|
|
|> Repo.stream()
|
|
|> Enum.to_list()
|
|
end
|
|
|> Repo.transaction()
|
|
|> case do
|
|
{:ok, ids} -> ids
|
|
{:error, _} -> []
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the list of organizations for a user.
|
|
"""
|
|
@spec list_user_organizations(String.t()) :: [Organization.t()]
|
|
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.
|
|
"""
|
|
@spec user_has_access?(String.t(), String.t()) :: boolean()
|
|
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.
|
|
"""
|
|
@spec get_organization!(String.t()) :: Organization.t()
|
|
def get_organization!(id), do: Repo.get!(Organization, id)
|
|
|
|
@doc """
|
|
Gets a single organization by slug.
|
|
"""
|
|
@spec get_organization_by_slug!(String.t()) :: Organization.t()
|
|
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)
|
|
"""
|
|
@spec create_organization(map(), String.t(), Keyword.t()) ::
|
|
{:ok, Organization.t()} | {:error, Ecto.Changeset.t()}
|
|
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"
|
|
|
|
do_create_organization(attrs, user_id, bypass_limits, subscription_plan)
|
|
end
|
|
|
|
# `Ecto.Multi.new/0` returns `%Multi{names: MapSet.new()}`. Dialyzer's
|
|
# success-typing sees the concrete `%MapSet{map: %{}}` value, which
|
|
# violates MapSet's `@opaque t` when passed to subsequent `Ecto.Multi.*`
|
|
# calls. This is an upstream quirk of Ecto exposing MapSet in the struct
|
|
# without an opacity-preserving `@spec`.
|
|
@dialyzer {:no_opaque, [do_create_organization: 4]}
|
|
@spec do_create_organization(map(), String.t(), boolean(), String.t()) ::
|
|
{:ok, Organization.t()} | {:error, Ecto.Changeset.t()}
|
|
defp do_create_organization(attrs, user_id, bypass_limits, subscription_plan) do
|
|
multi =
|
|
Ecto.Multi.new()
|
|
# Step 1: Lock user to prevent concurrent org creation
|
|
|> Ecto.Multi.run(:lock_user, fn repo, _changes ->
|
|
user = repo.one!(from u in User, where: u.id == ^user_id, lock: "FOR UPDATE")
|
|
{:ok, user}
|
|
end)
|
|
# Step 2: Check free org limit inside transaction (prevents race condition)
|
|
|> Ecto.Multi.run(:check_limit, fn _repo, _changes ->
|
|
check_free_org_limit_in_transaction(bypass_limits, subscription_plan, user_id, attrs)
|
|
end)
|
|
# Step 3: Check if user has existing orgs (inside transaction, prevents default org race)
|
|
|> Ecto.Multi.run(:check_existing, fn repo, _changes ->
|
|
has_existing = repo.exists?(from m in Membership, where: m.user_id == ^user_id)
|
|
{:ok, has_existing}
|
|
end)
|
|
# Step 4: Create organization
|
|
|> Ecto.Multi.insert(:organization, Organization.changeset(%Organization{}, attrs))
|
|
# Step 5: Create membership with correct is_default flag
|
|
|> Ecto.Multi.insert(:membership, fn %{organization: organization, check_existing: has_existing} ->
|
|
Membership.changeset(%Membership{}, %{
|
|
organization_id: organization.id,
|
|
user_id: user_id,
|
|
role: :owner,
|
|
# Set as default if this is the user's first organization
|
|
is_default: !has_existing
|
|
})
|
|
end)
|
|
|
|
case Repo.transaction(multi) do
|
|
{:ok, %{organization: organization}} -> {:ok, organization}
|
|
{:error, :check_limit, changeset, _} -> {:error, changeset}
|
|
{:error, :organization, changeset, _} -> {:error, changeset}
|
|
{:error, :membership, changeset, _} -> {:error, changeset}
|
|
{:error, _step, reason, _} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
# Check free org limit - for use inside transaction
|
|
defp check_free_org_limit_in_transaction(true, _plan, _user_id, _attrs), do: {:ok, :bypassed}
|
|
defp check_free_org_limit_in_transaction(_bypass, plan, _user_id, _attrs) when plan != "free", do: {:ok, :not_free}
|
|
|
|
defp check_free_org_limit_in_transaction(false, "free", user_id, attrs) do
|
|
# Count is executed inside the transaction, so it's safe from race conditions
|
|
count = SubscriptionLimits.count_user_owned_free_organizations(user_id)
|
|
|
|
if count < 1 do
|
|
{:ok, :allowed}
|
|
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
|
|
|
|
@doc """
|
|
Updates an organization.
|
|
"""
|
|
def update_organization(%Organization{} = organization, attrs) do
|
|
old_config = ConfigChangeTracker.capture_config_state(organization)
|
|
old_use_sites = organization.use_sites
|
|
|
|
with {:ok, updated_organization} <- organization |> Organization.changeset(attrs) |> Repo.update() do
|
|
propagate_organization_changes(updated_organization, old_config, old_use_sites)
|
|
{:ok, updated_organization}
|
|
end
|
|
end
|
|
|
|
defp propagate_organization_changes(updated_organization, old_config, old_use_sites) do
|
|
propagate_snmp_changes(updated_organization, old_config)
|
|
propagate_mikrotik_changes(updated_organization, old_config)
|
|
handle_sites_disabled(updated_organization, old_use_sites)
|
|
end
|
|
|
|
defp propagate_snmp_changes(updated_organization, old_config) do
|
|
if ConfigChangeTracker.snmp_changed?(updated_organization, old_config) do
|
|
Towerops.Devices.propagate_organization_community_change(
|
|
updated_organization.id,
|
|
updated_organization.snmp_community
|
|
)
|
|
end
|
|
end
|
|
|
|
defp propagate_mikrotik_changes(updated_organization, old_config) do
|
|
if ConfigChangeTracker.mikrotik_changed?(updated_organization, old_config) do
|
|
mikrotik_attrs = ConfigChangeTracker.extract_mikrotik_attrs(updated_organization)
|
|
|
|
Towerops.Devices.propagate_organization_mikrotik_change(
|
|
updated_organization.id,
|
|
mikrotik_attrs
|
|
)
|
|
end
|
|
end
|
|
|
|
defp handle_sites_disabled(updated_organization, old_use_sites) do
|
|
if old_use_sites && !updated_organization.use_sites do
|
|
clear_all_site_assignments(updated_organization.id)
|
|
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 all members for an organization with preloaded users.
|
|
Ordered by role priority (owner first) then email.
|
|
"""
|
|
def list_organization_members(organization_id) do
|
|
Repo.all(
|
|
from(m in Membership,
|
|
where: m.organization_id == ^organization_id,
|
|
join: u in assoc(m, :user),
|
|
preload: [user: u],
|
|
order_by: [
|
|
fragment(
|
|
"CASE ? WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'member' THEN 2 WHEN 'viewer' THEN 3 END",
|
|
m.role
|
|
),
|
|
asc: u.email
|
|
]
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Removes a member from an organization. Owners cannot be removed.
|
|
"""
|
|
def remove_member(organization_id, user_id) do
|
|
case get_membership(organization_id, user_id) do
|
|
%Membership{role: :owner} -> {:error, :cannot_remove_owner}
|
|
%Membership{} = membership -> delete_membership(membership)
|
|
nil -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Updates a member's role. Owners cannot have their role changed.
|
|
"""
|
|
def update_member_role(organization_id, user_id, new_role) do
|
|
case get_membership(organization_id, user_id) do
|
|
%Membership{role: :owner} -> {:error, :cannot_change_owner_role}
|
|
%Membership{} = membership -> update_membership(membership, %{role: new_role})
|
|
nil -> {:error, :not_found}
|
|
end
|
|
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 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
|
|
|
|
@doc """
|
|
Gets the user's default organization membership.
|
|
"""
|
|
def get_default_membership(user_id) do
|
|
Repo.one(
|
|
from m in Membership,
|
|
where: m.user_id == ^user_id and m.is_default == true,
|
|
preload: [:organization]
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Sets an organization as the user's default.
|
|
Clears any existing default and sets the new one.
|
|
"""
|
|
# Same MapSet-opacity issue as do_create_organization/4 above.
|
|
@dialyzer {:no_opaque, [set_default_organization: 2]}
|
|
@spec set_default_organization(String.t(), String.t()) ::
|
|
{:ok, Organization.t()} | {:error, Ecto.Changeset.t() | :not_found}
|
|
def set_default_organization(user_id, organization_id) do
|
|
# Verify user has access to this organization
|
|
if user_has_access?(user_id, organization_id) do
|
|
multi =
|
|
Ecto.Multi.new()
|
|
|> Ecto.Multi.update_all(:clear_old_default, clear_default_query(user_id), set: [is_default: false])
|
|
|> Ecto.Multi.update_all(
|
|
:set_new_default,
|
|
set_default_query(user_id, organization_id),
|
|
set: [is_default: true]
|
|
)
|
|
|
|
case Repo.transaction(multi) do
|
|
{:ok, _} -> {:ok, get_organization!(organization_id)}
|
|
{:error, _, changeset, _} -> {:error, changeset}
|
|
end
|
|
else
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp clear_default_query(user_id) do
|
|
from m in Membership,
|
|
where: m.user_id == ^user_id and m.is_default == true
|
|
end
|
|
|
|
defp set_default_query(user_id, organization_id) do
|
|
from m in Membership,
|
|
where: m.user_id == ^user_id and m.organization_id == ^organization_id
|
|
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
|
|
case %Invitation{}
|
|
|> Invitation.changeset(attrs)
|
|
|> Repo.insert() do
|
|
{:ok, invitation} ->
|
|
{:ok, Repo.preload(invitation, :invited_by)}
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets an invitation by ID for a specific organization.
|
|
Returns nil if the invitation doesn't exist or doesn't belong to the organization.
|
|
"""
|
|
def get_organization_invitation(invitation_id, organization_id) do
|
|
Repo.one(
|
|
from(i in Invitation,
|
|
where: i.id == ^invitation_id,
|
|
where: i.organization_id == ^organization_id
|
|
)
|
|
)
|
|
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.
|
|
"""
|
|
# Same MapSet-opacity issue as do_create_organization/4 above.
|
|
@dialyzer {:no_opaque, [accept_invitation: 2]}
|
|
@spec accept_invitation(Invitation.t(), String.t()) ::
|
|
{:ok, Membership.t()} | {:error, Ecto.Changeset.t()}
|
|
def accept_invitation(invitation, user_id) do
|
|
multi =
|
|
Ecto.Multi.new()
|
|
|> Ecto.Multi.update(:invitation, fn _ ->
|
|
Ecto.Changeset.change(invitation, %{
|
|
accepted_at: Towerops.Time.now(),
|
|
accepted_by_id: user_id
|
|
})
|
|
end)
|
|
|> Ecto.Multi.insert(: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: Towerops.Time.now()
|
|
]
|
|
)
|
|
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 (includes devices with and without sites)
|
|
device_ids =
|
|
Repo.all(from(e in Device, where: e.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 = Towerops.Time.now()
|
|
|
|
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, where: e.organization_id == ^organization_id, select: e.id))
|
|
|
|
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Clears all site_id assignments for devices in an organization when sites are disabled.
|
|
|
|
Returns the number of device records updated.
|
|
"""
|
|
def clear_all_site_assignments(organization_id) do
|
|
# Clear site assignments from all devices
|
|
Repo.update_all(
|
|
from(d in Device, where: d.organization_id == ^organization_id),
|
|
set: [
|
|
site_id: nil,
|
|
updated_at: Towerops.Time.now()
|
|
]
|
|
)
|
|
|
|
# Delete all sites for the organization
|
|
Repo.delete_all(from(s in Site, where: s.organization_id == ^organization_id))
|
|
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
|
|
|
|
## Billing
|
|
|
|
@doc """
|
|
Gets an organization by Stripe customer ID.
|
|
|
|
Returns nil if not found.
|
|
"""
|
|
@spec get_organization_by_stripe_customer_id(String.t()) :: Organization.t() | nil
|
|
def get_organization_by_stripe_customer_id(stripe_customer_id) do
|
|
Repo.get_by(Organization, stripe_customer_id: stripe_customer_id)
|
|
end
|
|
|
|
@doc """
|
|
Lists all organizations with active paid subscriptions.
|
|
|
|
Returns organizations where subscription_status is "active" or "trialing".
|
|
Used by BillingSyncWorker to sync usage to Stripe.
|
|
"""
|
|
@spec list_organizations_with_active_subscriptions() :: [Organization.t()]
|
|
def list_organizations_with_active_subscriptions do
|
|
Repo.all(
|
|
from o in Organization,
|
|
where: o.subscription_status in ["active", "trialing"],
|
|
where: not is_nil(o.stripe_customer_id)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Gets the primary owner of an organization.
|
|
|
|
Returns the user with the :owner role. If multiple owners exist (shouldn't happen),
|
|
returns the first one by insertion date.
|
|
"""
|
|
@spec get_primary_owner(Organization.t()) :: User.t()
|
|
def get_primary_owner(%Organization{} = organization) do
|
|
Repo.one!(
|
|
from m in Membership,
|
|
join: u in assoc(m, :user),
|
|
where: m.organization_id == ^organization.id,
|
|
where: m.role == :owner,
|
|
order_by: [desc: m.is_default, asc: m.inserted_at, asc: m.id],
|
|
limit: 1,
|
|
select: u
|
|
)
|
|
end
|
|
end
|