18 KiB
Refactor Plan: Make Sites Optional in Towerops
Goal
Remove the "create site first" wall for new users. Allow devices to belong directly to organizations without requiring site creation, while preserving backward compatibility for existing multi-site users.
Architecture Decision
Approach: True optional sites with nullable site_id
- Add
organization_idforeign key directly to devices table - Make
site_idnullable - Support 2-tier credential cascade (org → device) for site-less devices
- Preserve 3-tier cascade (org → site → device) when site is present
Current State Analysis
Database Schema
Organization (id)
└── Site (organization_id, NOT NULL)
└── Device (site_id, NOT NULL) ← Devices MUST have site
Problems:
- Devices have NO direct
organization_id- only indirect viasite.organization_id - Authorization checks crash if site is nil:
device.site.organization_id - Credential resolution crashes:
device.site.snmp_community
Code Impact
- 42 functions access
device.site.*pattern and will crash if site is nil - 17 credential resolution functions in
lib/towerops/devices.exneed refactoring - 5 API controller functions use
device.site.organization_idfor auth - 3-tier credential cascade for SNMP v2, SNMP v3, and MikroTik credentials
MVP Scope (Minimal Viable Onboarding Fix)
In Scope ✅
- Database migrations (add organization_id, make site_id nullable)
- Core credential resolution refactor (CredentialResolver module)
- Device form - remove site requirement, make site optional
- Basic authorization fixes (API controllers, agent channel)
- Core query functions (list_organization_devices, etc.)
Out of Scope (Future Enhancements) 🚫
- Progressive disclosure UI (
use_sitestoggle in org settings) - Conditional navigation (hiding "Sites" link)
- Device grouping UI changes (flat vs grouped view)
- Automatic default site creation
- Settings page site toggle
- Help text and education materials
- Mobile app changes
Rationale: Focus on core backend functionality first. UI polish can come in follow-up iterations.
Implementation Plan
Phase 1: Database Schema Changes
Migration 1: Add organization_id (with backfill)
File: priv/repo/migrations/XXXXXX_add_organization_id_to_devices.exs
def up do
# 1. Add nullable organization_id column
alter table(:devices) do
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all)
end
# 2. Backfill from existing site relationships
execute """
UPDATE devices d
SET organization_id = s.organization_id
FROM sites s
WHERE d.site_id = s.id
"""
# 3. Make organization_id NOT NULL (data is backfilled)
alter table(:devices) do
modify :organization_id, :binary_id, null: false
end
# 4. Add indexes
create index(:devices, [:organization_id])
create index(:devices, [:organization_id, :display_order, :name])
# 5. Add check constraint (site must belong to same org)
create constraint(:devices, :devices_site_organization_match,
check: """
site_id IS NULL OR
organization_id = (SELECT organization_id FROM sites WHERE id = site_id)
"""
)
end
def down do
drop constraint(:devices, :devices_site_organization_match)
drop index(:devices, [:organization_id, :display_order, :name])
drop index(:devices, [:organization_id])
alter table(:devices) do
remove :organization_id
end
end
Safety:
- No downtime (backfill happens before NOT NULL)
- Rollbackable (can drop organization_id column)
- Check constraint prevents invalid data
Migration 2: Make site_id nullable
File: priv/repo/migrations/XXXXXX_make_site_id_nullable_on_devices.exs
def up do
alter table(:devices) do
modify :site_id, :binary_id, null: true, from: {:binary_id, null: false}
end
end
def down do
# Cannot rollback if NULL site_id values exist
execute """
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM devices WHERE site_id IS NULL) THEN
RAISE EXCEPTION 'Cannot revert: devices with NULL site_id exist';
END IF;
END $$;
"""
alter table(:devices) do
modify :site_id, :binary_id, null: false
end
end
Safety: One-way migration once devices without sites exist
Phase 2: Core Schema Updates
Update Device Schema
File: lib/towerops/devices/device.ex
Changes:
- Add
belongs_to :organization, Organization(new field) - Change
validate_requiredto include:organization_id, remove:site_id - Add
validate_site_belongs_to_organization/1function - Update typespec to show
site_id: UUID.t() | nil
Key validation:
defp validate_site_belongs_to_organization(changeset) do
site_id = get_field(changeset, :site_id)
org_id = get_field(changeset, :organization_id)
if site_id && org_id do
site = Repo.get!(Site, site_id)
if site.organization_id != org_id do
add_error(changeset, :site_id, "must belong to the same organization")
else
changeset
end
else
changeset
end
end
Phase 3: Credential Resolution Refactor
Create CredentialResolver Module
File: lib/towerops/devices/credential_resolver.ex (new file)
Centralizes safe credential resolution with 2-tier and 3-tier support:
defmodule Towerops.Devices.CredentialResolver do
@moduledoc """
Safe credential resolution with optional sites.
Supports:
- 2-tier: device → organization (when site_id is nil)
- 3-tier: device → site → organization (when site_id present)
"""
def ensure_credentials_loaded(%Device{} = device) do
cond do
!Ecto.assoc_loaded?(device.organization) ->
preload_all(device)
device.site_id && !Ecto.assoc_loaded?(device.site) ->
Repo.preload(device, [:site])
true ->
device
end
end
defp preload_all(device) do
if device.site_id do
Repo.preload(device, [:organization, :site])
else
Repo.preload(device, [:organization])
end
end
def resolve_field(device, device_field, site_field, org_field, opts \\ []) do
default = Keyword.get(opts, :default)
device = ensure_credentials_loaded(device)
device_value = Map.get(device, device_field)
site_value =
if device.site_id && device.site do
Map.get(device.site, site_field)
else
nil
end
org_value = Map.get(device.organization, org_field)
device_value || site_value || org_value || default
end
def determine_source(device, device_field, site_field, org_field) do
device = ensure_credentials_loaded(device)
cond do
Map.get(device, device_field) != nil -> :device
device.site_id && device.site && Map.get(device.site, site_field) != nil -> :site
Map.get(device.organization, org_field) != nil -> :organization
true -> :default
end
end
end
Update Credential Resolution Functions
File: lib/towerops/devices.ex
Refactor these functions (lines 299-628):
resolve_snmp_config/1- SNMP v2c credentialsresolve_snmpv3_config/1- SNMP v3 credentials (6 fields)resolve_mikrotik_config/1- MikroTik API credentials (5 fields)
Pattern:
# BEFORE (crashes if site is nil):
defp resolve_snmp_community(device) do
device.snmp_community ||
device.site.snmp_community || # ⚠️ Crashes
device.site.organization.snmp_community
end
# AFTER (safe with CredentialResolver):
alias Towerops.Devices.CredentialResolver
defp resolve_snmp_community(device) do
CredentialResolver.resolve_field(
device,
:snmp_community,
:snmp_community,
:snmp_community
)
end
Phase 4: Query Function Updates
Update Organization Device Queries
File: lib/towerops/devices.ex
Functions to update:
list_organization_devices/2(lines 39-63)count_organization_devices/1(lines 83-91)list_devices_for_organizations/1(lines 68-78)list_mikrotik_devices_with_api/0(lines 141-156)
Pattern:
# BEFORE (requires site join):
def list_organization_devices(organization_id, filters) do
from(d in Device,
join: s in assoc(d, :site), # ⚠️ Inner join excludes site-less devices
where: s.organization_id == ^organization_id,
preload: [site: s]
)
end
# AFTER (direct organization_id):
def list_organization_devices(organization_id, filters) do
from(d in Device,
where: d.organization_id == ^organization_id,
order_by: [asc: d.display_order, asc: d.name],
preload: [:site, :organization] # Left join - includes site-less devices
)
end
Update Preload Functions
Add safe preload helper:
def preload_device_with_associations(device) do
if device.site_id do
Repo.preload(device, [:organization, :site])
else
Repo.preload(device, [:organization])
end
end
Update get_device/1, get_device!/1, get_device_with_details/1 to use it.
Phase 5: Authorization Fixes
API Controllers
File: lib/towerops_web/controllers/api/v1/devices_controller.ex
Update authorization checks (lines 147, 190, 236):
# BEFORE:
if device.site.organization_id == organization_id do # ⚠️ Crashes
# AFTER:
if device.organization_id == organization_id do # ✅ Safe
Update site verification (line 260):
# BEFORE:
defp verify_site_access(nil, _organization_id),
do: {:error, "site_id is required"}
# AFTER (site is optional):
defp verify_site_access(nil, _organization_id),
do: :ok
Agent Channel
File: lib/towerops_web/channels/agent_channel.ex
Update device verification (line 639):
# BEFORE:
if is_nil(organization_id) or device.site.organization_id == organization_id do
# AFTER:
if is_nil(organization_id) or device.organization_id == organization_id do
Phase 6: Device Form Updates
Remove Site Requirement
File: lib/towerops_web/live/device_live/form.ex
Remove blocking check (lines 34-38):
# DELETE THIS:
if Enum.empty?(sites) do
{:ok,
socket
|> put_flash(:info, "Please create a site before adding a device.")
|> push_navigate(to: ~p"/sites/new")}
Update changeset to auto-inject organization_id:
def handle_event("save", %{"device" => device_params}, socket) do
organization = socket.assigns.current_scope.organization
# Inject organization_id from current context
device_params = Map.put(device_params, "organization_id", organization.id)
save_device(socket, socket.assigns.live_action, device_params)
end
Make Site Selector Optional in Form
File: lib/towerops_web/live/device_live/form.html.heex
Update site input (lines 168-177):
<!-- Site is now optional -->
<div class="col-span-full">
<.input
field={@form[:site_id]}
type="select"
label="Site (Optional)"
prompt="No site (ungrouped)"
options={Enum.map(@available_sites, &{&1.name, &1.id})}
/>
<p class="mt-1 text-sm text-gray-500">
Optionally group this device into a site (office, datacenter, etc.)
</p>
</div>
Phase 7: API Updates
Device Create Endpoint
File: lib/towerops_web/controllers/api/v1/devices_controller.ex
Update to auto-inject organization_id:
def create(conn, %{"device" => device_params}) do
organization_id = conn.assigns.current_organization_id
# Inject organization_id from authenticated context
device_params = Map.put(device_params, "organization_id", organization_id)
# site_id is now optional
with :ok <- verify_site_access(device_params["site_id"], organization_id),
{:ok, device} <- Devices.create_device(device_params) do
# ...
end
end
Phase 8: Agent Assignment Updates
Safe Agent Resolution
File: lib/towerops/devices.ex (lines 167-189)
Update agent fallback chain:
def resolve_agent_token_id(device) do
device =
if device.site_id do
Repo.preload(device, [:organization, :site, agent_assignments: []])
else
Repo.preload(device, [:organization, agent_assignments: []])
end
get_direct_agent(device) ||
get_site_agent(device) ||
get_org_default_agent(device) ||
get_global_default_agent()
end
# Safe: only access site if site_id present
defp get_site_agent(device) do
if device.site_id && device.site do
device.site.agent_token_id
else
nil
end
end
# Safe: use direct organization relationship
defp get_org_default_agent(device) do
device.organization.default_agent_token_id
end
Phase 9: Propagation Functions
Update Credential Propagation
File: lib/towerops/devices.ex
Functions to update:
propagate_organization_community_change/2(lines 905-934)propagate_organization_mikrotik_change/2(lines 490-521)propagate_organization_snmpv3_change/2(lines 662-688)
Pattern:
# BEFORE (inner join excludes site-less devices):
from(d in Device,
join: s in assoc(d, :site),
where: s.organization_id == ^organization_id,
...
)
# AFTER (includes site-less devices):
from(d in Device,
left_join: s in assoc(d, :site),
where: d.organization_id == ^organization_id,
where: (is_nil(d.site_id)) or (is_nil(s.snmp_community) or s.snmp_community == ""),
...
)
Phase 10: Device Quota Validation
Update Quota Checks
File: lib/towerops/devices.ex (lines 727-756)
Change to use organization_id:
# BEFORE (derives org from site):
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 ->
{:ok, changeset}
end
end
defp validate_device_quota(changeset, site_id) do
site = site_id |> Sites.get_site!() |> Repo.preload(:organization)
organization = site.organization
# ...
end
# AFTER (uses organization_id directly):
defp do_check_quota(changeset) do
case Ecto.Changeset.fetch_change(changeset, :organization_id) do
{:ok, organization_id} ->
validate_device_quota(changeset, organization_id)
:error ->
{:ok, changeset}
end
end
defp validate_device_quota(changeset, organization_id) do
organization = Repo.get!(Organization, organization_id)
# ...
end
Testing Strategy
Unit Tests
File: test/towerops/devices/credential_resolver_test.exs (new)
Test scenarios:
- 3-tier resolution (device with site): device → site → org → default
- 2-tier resolution (device without site): device → org → default
- All credential types (SNMP v2, SNMP v3, MikroTik)
File: test/towerops/devices_test.exs
Add tests for:
get_snmp_config/1with and without siteresolve_agent_token_id/1with and without site- Query functions return site-less devices
- Propagation functions handle site-less devices
Integration Tests
File: test/towerops_web/controllers/api/v1/devices_controller_test.exs
Test scenarios:
- Create device without site_id → succeeds
- Create device with site_id from different org → fails
- Show/update/delete device without site → succeeds
File: test/towerops_web/live/device_live_test.exs
Test scenarios:
- Navigate to /devices/new without sites → no redirect
- Submit device form without site_id → succeeds
- Device appears in list view
Migration Tests
Verify:
- Migration 1 backfills all organization_id values correctly
- Check constraint prevents mismatched site/org
- Migration 2 rollback blocked when NULL site_id exists
Implementation Sequence
Day 1-2: Schema Foundation
- Write and test migration 1 (add organization_id)
- Update Device schema module
- Write CredentialResolver module
- Add unit tests for CredentialResolver
Day 3-4: Credential Resolution
- Refactor SNMP credential resolution
- Refactor MikroTik credential resolution
- Refactor SNMPv3 credential resolution
- Update agent assignment resolution
- Add integration tests
Day 5-6: Query Updates
- Update list_organization_devices and related functions
- Update preload functions
- Update propagation functions
- Update quota validation
- Add query tests
Day 7-8: Authorization & Forms
- Update API controller authorization
- Update agent channel authorization
- Update device form (remove site wall, make optional)
- Update API create endpoint
- Add E2E tests
Day 9: Migration & Deployment
- Write and test migration 2 (make site_id nullable)
- Run migrations in staging
- Manual testing with real devices
- Deploy to production
Day 10: Validation
- Monitor error rates
- Test creating devices without sites
- Verify existing devices still work
- Performance testing
Critical Files Summary
Migrations (New)
priv/repo/migrations/XXXXXX_add_organization_id_to_devices.exspriv/repo/migrations/XXXXXX_make_site_id_nullable_on_devices.exs
Schema & Context (Modified)
lib/towerops/devices/device.ex- Add organization_id field, validationlib/towerops/devices/credential_resolver.ex- New module for safe resolutionlib/towerops/devices.ex- 30+ functions need updates
Controllers & LiveViews (Modified)
lib/towerops_web/controllers/api/v1/devices_controller.ex- Auth fixeslib/towerops_web/channels/agent_channel.ex- Auth fixeslib/towerops_web/live/device_live/form.ex- Remove site walllib/towerops_web/live/device_live/form.html.heex- Optional site selector
Tests (New/Modified)
test/towerops/devices/credential_resolver_test.exs- New unit teststest/towerops/devices_test.exs- Updated integration teststest/towerops_web/controllers/api/v1/devices_controller_test.exs- API teststest/towerops_web/live/device_live_test.exs- LiveView tests
Rollback Plan
If issues arise:
- Revert application code (Git revert)
- Keep migration 1 (organization_id column is additive, doesn't break anything)
- Revert migration 2 (only if no NULL site_id values exist)
- System continues working with 3-tier credential resolution only
Success Criteria
✅ New user registers → can add device immediately (no site required) ✅ Existing multi-site users see no change in behavior ✅ All credential resolution works (SNMP, SNMPv3, MikroTik) ✅ Agent polling continues for all devices ✅ API endpoints work for site-less devices ✅ No N+1 query regressions ✅ All tests passing (unit, integration, E2E) ✅ Zero downtime deployment
Future Enhancements (Post-MVP)
After core functionality is stable, consider:
- Progressive disclosure UI (
use_sitestoggle in org settings) - Conditional navigation (hide "Sites" link when unused)
- Device grouping UI (flat vs grouped view)
- Auto-create default site on org creation
- Help text and onboarding education
- Mobile app updates
- Advanced site features (hierarchical sites, site tags, etc.)