towerops/docs/plans/make-sites-optional.md

686 lines
18 KiB
Markdown

# 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_id` foreign key directly to devices table
- Make `site_id` nullable
- 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 via `site.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.ex` need refactoring
- **5 API controller functions** use `device.site.organization_id` for auth
- **3-tier credential cascade** for SNMP v2, SNMP v3, and MikroTik credentials
## MVP Scope (Minimal Viable Onboarding Fix)
### In Scope ✅
1. Database migrations (add organization_id, make site_id nullable)
2. Core credential resolution refactor (CredentialResolver module)
3. Device form - remove site requirement, make site optional
4. Basic authorization fixes (API controllers, agent channel)
5. Core query functions (list_organization_devices, etc.)
### Out of Scope (Future Enhancements) 🚫
- Progressive disclosure UI (`use_sites` toggle 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`
```elixir
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`
```elixir
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**:
1. Add `belongs_to :organization, Organization` (new field)
2. Change `validate_required` to include `:organization_id`, remove `:site_id`
3. Add `validate_site_belongs_to_organization/1` function
4. Update typespec to show `site_id: UUID.t() | nil`
**Key validation**:
```elixir
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:
```elixir
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 credentials
- `resolve_snmpv3_config/1` - SNMP v3 credentials (6 fields)
- `resolve_mikrotik_config/1` - MikroTik API credentials (5 fields)
**Pattern**:
```elixir
# 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**:
1. `list_organization_devices/2` (lines 39-63)
2. `count_organization_devices/1` (lines 83-91)
3. `list_devices_for_organizations/1` (lines 68-78)
4. `list_mikrotik_devices_with_api/0` (lines 141-156)
**Pattern**:
```elixir
# 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:
```elixir
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):
```elixir
# BEFORE:
if device.site.organization_id == organization_id do # ⚠️ Crashes
# AFTER:
if device.organization_id == organization_id do # ✅ Safe
```
**Update site verification** (line 260):
```elixir
# 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):
```elixir
# 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):
```elixir
# 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**:
```elixir
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):
```heex
<!-- 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**:
```elixir
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**:
```elixir
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**:
```elixir
# 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**:
```elixir
# 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/1` with and without site
- `resolve_agent_token_id/1` with 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
1. Write and test migration 1 (add organization_id)
2. Update Device schema module
3. Write CredentialResolver module
4. Add unit tests for CredentialResolver
### Day 3-4: Credential Resolution
1. Refactor SNMP credential resolution
2. Refactor MikroTik credential resolution
3. Refactor SNMPv3 credential resolution
4. Update agent assignment resolution
5. Add integration tests
### Day 5-6: Query Updates
1. Update list_organization_devices and related functions
2. Update preload functions
3. Update propagation functions
4. Update quota validation
5. Add query tests
### Day 7-8: Authorization & Forms
1. Update API controller authorization
2. Update agent channel authorization
3. Update device form (remove site wall, make optional)
4. Update API create endpoint
5. Add E2E tests
### Day 9: Migration & Deployment
1. Write and test migration 2 (make site_id nullable)
2. Run migrations in staging
3. Manual testing with real devices
4. Deploy to production
### Day 10: Validation
1. Monitor error rates
2. Test creating devices without sites
3. Verify existing devices still work
4. Performance testing
---
## Critical Files Summary
### Migrations (New)
- `priv/repo/migrations/XXXXXX_add_organization_id_to_devices.exs`
- `priv/repo/migrations/XXXXXX_make_site_id_nullable_on_devices.exs`
### Schema & Context (Modified)
- `lib/towerops/devices/device.ex` - Add organization_id field, validation
- `lib/towerops/devices/credential_resolver.ex` - New module for safe resolution
- `lib/towerops/devices.ex` - 30+ functions need updates
### Controllers & LiveViews (Modified)
- `lib/towerops_web/controllers/api/v1/devices_controller.ex` - Auth fixes
- `lib/towerops_web/channels/agent_channel.ex` - Auth fixes
- `lib/towerops_web/live/device_live/form.ex` - Remove site wall
- `lib/towerops_web/live/device_live/form.html.heex` - Optional site selector
### Tests (New/Modified)
- `test/towerops/devices/credential_resolver_test.exs` - New unit tests
- `test/towerops/devices_test.exs` - Updated integration tests
- `test/towerops_web/controllers/api/v1/devices_controller_test.exs` - API tests
- `test/towerops_web/live/device_live_test.exs` - LiveView tests
---
## Rollback Plan
If issues arise:
1. **Revert application code** (Git revert)
2. **Keep migration 1** (organization_id column is additive, doesn't break anything)
3. **Revert migration 2** (only if no NULL site_id values exist)
4. 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_sites` toggle 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.)