From fc66109f19082ed196f33d00072f8d599b89e3c1 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 4 Feb 2026 16:09:43 -0600 Subject: [PATCH] Add default organization feature Features: - Users can set a default organization in org settings - First organization created is automatically set as default - Default org selector only shown if user has 2+ organizations - Migration auto-sets first org as default for existing users Database changes: - Added is_default boolean field to organization_memberships - Added partial index for efficient default org lookups - Migration sets oldest org as default for each existing user UI changes: - Organization settings page shows default org toggle - Green badge displayed when org is already default - Button to set org as default if it's not current default Backend: - Organizations.set_default_organization/2 manages defaults - Organizations.get_default_membership/1 retrieves default - Auto-set first org as default in create_organization/3 --- lib/towerops/organizations.ex | 70 +++++++++++++++---- lib/towerops/organizations/membership.ex | 4 +- lib/towerops_web/live/org/settings_live.ex | 33 +++++++++ .../live/org/settings_live.html.heex | 41 +++++++++++ ...is_default_to_organization_memberships.exs | 29 ++++++++ 5 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 priv/repo/migrations/20260204220500_add_is_default_to_organization_memberships.exs diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index e663a6be..4c048d28 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -93,6 +93,13 @@ defmodule Towerops.Organizations do end defp do_create_organization(attrs, user_id) do + # Check if user has any existing organizations + has_existing_orgs = + Repo.exists?( + from m in Membership, + where: m.user_id == ^user_id + ) + multi = Ecto.Multi.new() |> Ecto.Multi.insert(:organization, Organization.changeset(%Organization{}, attrs)) @@ -100,27 +107,16 @@ defmodule Towerops.Organizations do Membership.changeset(%Membership{}, %{ organization_id: organization.id, user_id: user_id, - role: :owner + role: :owner, + # Set as default if this is the user's first organization + is_default: !has_existing_orgs }) end) - |> Ecto.Multi.run(:set_default, fn _repo, %{organization: organization} -> - # Set this as the user's default organization if they don't have one - user = Towerops.Accounts.get_user!(user_id) - - if user.default_organization_id == nil do - user - |> Ecto.Changeset.change(default_organization_id: organization.id) - |> Repo.update() - else - {:ok, user} - end - end) case Repo.transaction(multi) do {:ok, %{organization: organization}} -> {:ok, organization} {:error, :organization, changeset, _} -> {:error, changeset} {:error, :membership, changeset, _} -> {:error, changeset} - {:error, :set_default, changeset, _} -> {:error, changeset} end end @@ -273,6 +269,52 @@ defmodule Towerops.Organizations 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. + """ + 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 """ diff --git a/lib/towerops/organizations/membership.ex b/lib/towerops/organizations/membership.ex index 56af12e2..a4eb42a0 100644 --- a/lib/towerops/organizations/membership.ex +++ b/lib/towerops/organizations/membership.ex @@ -16,6 +16,7 @@ defmodule Towerops.Organizations.Membership do @foreign_key_type :binary_id schema "organization_memberships" do field :role, Ecto.Enum, values: [:owner, :admin, :member, :viewer] + field :is_default, :boolean, default: false belongs_to :organization, Organization belongs_to :user, User @@ -26,6 +27,7 @@ defmodule Towerops.Organizations.Membership do @type t :: %__MODULE__{ id: Ecto.UUID.t(), role: :owner | :admin | :member | :viewer, + is_default: boolean(), organization_id: Ecto.UUID.t(), organization: NotLoaded.t() | Organization.t(), user_id: Ecto.UUID.t(), @@ -37,7 +39,7 @@ defmodule Towerops.Organizations.Membership do @doc false def changeset(membership, attrs) do membership - |> cast(attrs, [:role, :organization_id, :user_id]) + |> cast(attrs, [:role, :organization_id, :user_id, :is_default]) |> validate_required([:role, :organization_id, :user_id]) |> foreign_key_constraint(:organization_id) |> foreign_key_constraint(:user_id) diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 67e04b44..6d848ce2 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -15,6 +15,12 @@ defmodule ToweropsWeb.Org.SettingsLive do # Get assignment breakdown to show impact assignment_breakdown = Agents.Stats.get_device_assignment_breakdown(organization.id) + # Get user's membership for this org to check if it's default + membership = Organizations.get_membership(organization.id, user.id) + + # Count user's total organizations + user_orgs_count = user.id |> Organizations.list_user_organizations() |> length() + # Log organization data access AuditLogger.log_org_data_accessed(nil, user.id, organization.id, "view_settings") @@ -23,6 +29,8 @@ defmodule ToweropsWeb.Org.SettingsLive do {:ok, socket |> assign(:organization, organization) + |> assign(:membership, membership) + |> assign(:user_orgs_count, user_orgs_count) |> assign(:available_agents, available_agents) |> assign(:assignment_breakdown, assignment_breakdown) |> assign(:form, to_form(changeset))} @@ -68,4 +76,29 @@ defmodule ToweropsWeb.Org.SettingsLive do {:noreply, put_flash(socket, :info, "Applied default agent to #{count} device records across all sites")} end + + @impl true + def handle_event("toggle_default_org", _params, socket) do + user = socket.assigns.current_scope.user + organization = socket.assigns.organization + membership = socket.assigns.membership + + if membership.is_default do + {:noreply, put_flash(socket, :error, "This is already your default organization")} + else + case Organizations.set_default_organization(user.id, organization.id) do + {:ok, _} -> + # Refresh membership to reflect change + updated_membership = Organizations.get_membership(organization.id, user.id) + + {:noreply, + socket + |> put_flash(:info, "#{organization.name} is now your default organization") + |> assign(:membership, updated_membership)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to set default organization")} + end + end + end end diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 8e88dca6..a72f65f0 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -48,6 +48,47 @@ /> + + <%= if @user_orgs_count > 1 do %> + +
+
+

+ Default Organization +

+

+ Set this organization as your default. When you log in, you'll be directed to your default organization. +

+
+ +
+ <%= if @membership.is_default do %> +
+
+ <.icon + name="hero-check-circle" + class="h-5 w-5 text-green-600 dark:text-green-400" + /> +

+ This is your default organization +

+
+
+ <% else %> + +

+ Click to make this your default organization +

+ <% end %> +
+
+ <% end %>
diff --git a/priv/repo/migrations/20260204220500_add_is_default_to_organization_memberships.exs b/priv/repo/migrations/20260204220500_add_is_default_to_organization_memberships.exs new file mode 100644 index 00000000..f0cf1970 --- /dev/null +++ b/priv/repo/migrations/20260204220500_add_is_default_to_organization_memberships.exs @@ -0,0 +1,29 @@ +defmodule Towerops.Repo.Migrations.AddIsDefaultToOrganizationMemberships do + use Ecto.Migration + + def change do + alter table(:organization_memberships) do + add :is_default, :boolean, default: false, null: false + end + + create index(:organization_memberships, [:user_id, :is_default], + where: "is_default = true", + name: :organization_memberships_user_id_default_index + ) + + # Set the first organization for each user as their default + execute( + """ + UPDATE organization_memberships + SET is_default = true + WHERE id IN ( + SELECT DISTINCT ON (user_id) id + FROM organization_memberships + ORDER BY user_id, inserted_at ASC + ) + """, + # Rollback: clear all defaults + "UPDATE organization_memberships SET is_default = false" + ) + end +end