towerops/lib/towerops/mobile_sessions.ex
graham ce7a8d196f fix: replace Repo.rollback with proper error handling for Oban compatibility (#201)
- Replace manual Repo.rollback/1 calls with raise/return patterns
- Fix typo: Repo.transact -> Repo.transaction
- Add unwrap_transaction_result/1 helper to reduce nesting depth
- Extract helper functions to satisfy Credo nesting depth requirements
- Fixes 'operation :rollback is rolling back unexpectedly' error

When Oban Pro's Smart engine uses Ecto.Multi for nested transactions,
manual Repo.rollback/1 calls break the transaction stack. This fix uses
proper error handling patterns:

1. Raise exceptions to trigger automatic rollback (admin.ex)
2. Return error tuples and unwrap with helper function (accounts.ex, mobile_sessions.ex)

Files changed:
- lib/towerops/admin.ex (3 functions: delete_user, delete_organization, update_billing_overrides)
- lib/towerops/accounts.ex (5 functions with helpers: update_user_email, reset_user_password, update_user_and_delete_all_tokens, do_update_user_and_delete_tokens, delete_account)
- lib/towerops/mobile_sessions.ex (2 functions with helper: complete_qr_login, do_complete_qr_login)

All tests passing (277 tests total).

Reviewed-on: graham/towerops-web#201
2026-03-28 09:11:21 -05:00

250 lines
5.7 KiB
Elixir

defmodule Towerops.MobileSessions do
@moduledoc """
Context for managing mobile app authentication sessions.
"""
import Ecto.Query
alias Towerops.MobileSessions.MobileSession
alias Towerops.MobileSessions.QRLoginToken
alias Towerops.Repo
@doc """
Creates a new mobile session for a user.
## Examples
iex> create_mobile_session(%{user_id: user.id, device_name: "iPhone 15"})
{:ok, %MobileSession{}}
"""
def create_mobile_session(attrs) do
%MobileSession{}
|> MobileSession.create_changeset(attrs)
|> Repo.insert()
end
@doc """
Gets a mobile session by ID.
"""
def get_session(session_id) do
Repo.get(MobileSession, session_id)
end
@doc """
Gets a mobile session by token.
Returns nil if the session is expired or doesn't exist.
"""
def get_session_by_token(token) when is_binary(token) do
now = DateTime.utc_now()
hashed = MobileSession.hash_token(token)
MobileSession
|> where([s], s.token == ^hashed)
|> where([s], s.expires_at > ^now)
|> Repo.one()
end
def get_session_by_token(_), do: nil
@doc """
Updates the last_used_at timestamp for a mobile session.
"""
def touch_session(%MobileSession{} = session) do
session
|> MobileSession.touch_changeset()
|> Repo.update()
end
@doc """
Lists all active mobile sessions for a user.
"""
def list_user_sessions(user_id) do
now = DateTime.utc_now()
MobileSession
|> where([s], s.user_id == ^user_id)
|> where([s], s.expires_at > ^now)
|> order_by([s], desc: s.last_used_at)
|> Repo.all()
end
@doc """
Revokes a mobile session by ID.
"""
def revoke_session(session_id) do
case Repo.get(MobileSession, session_id) do
nil -> {:error, :not_found}
session -> Repo.delete(session)
end
end
@doc """
Updates alert preferences for a mobile session.
"""
def update_alert_preferences(session_id, attrs) do
case Repo.get(MobileSession, session_id) do
nil ->
{:error, :not_found}
session ->
session
|> Ecto.Changeset.cast(attrs, [:alerts_enabled, :push_token, :push_platform])
|> Repo.update()
end
end
@doc """
Lists all devices with alerts enabled for a user.
Used for sending push notifications.
"""
def list_alert_enabled_devices(user_id) do
now = DateTime.utc_now()
MobileSession
|> where([s], s.user_id == ^user_id)
|> where([s], s.alerts_enabled == true)
|> where([s], s.expires_at > ^now)
|> where([s], not is_nil(s.push_token))
|> Repo.all()
end
@doc """
Revokes all mobile sessions for a user.
"""
def revoke_all_user_sessions(user_id) do
{count, _} =
MobileSession
|> where([s], s.user_id == ^user_id)
|> Repo.delete_all()
{:ok, count}
end
@doc """
Deletes expired mobile sessions.
Returns the number of sessions deleted.
"""
def delete_expired_sessions do
now = DateTime.utc_now()
{count, _} =
MobileSession
|> where([s], s.expires_at <= ^now)
|> Repo.delete_all()
count
end
# QR Login Token functions
@doc """
Creates a new QR login token for a user.
Token expires in 5 minutes.
"""
def create_qr_login_token(user_id) do
%QRLoginToken{}
|> QRLoginToken.create_changeset(%{user_id: user_id})
|> Repo.insert()
end
@doc """
Gets a QR login token by token string.
Returns nil if the token is expired, already used, or doesn't exist.
"""
def get_qr_login_token(token) when is_binary(token) do
now = DateTime.utc_now()
QRLoginToken
|> where([t], t.token == ^token)
|> where([t], t.expires_at > ^now)
|> where([t], is_nil(t.completed_at))
|> Repo.one()
end
def get_qr_login_token(_), do: nil
@doc """
Completes a QR login token by creating a mobile session.
Returns {:ok, mobile_session} on success.
"""
def complete_qr_login(token, device_attrs) do
token
|> do_complete_qr_login(device_attrs)
|> Repo.transaction()
|> unwrap_transaction_result()
end
defp do_complete_qr_login(token, device_attrs) do
fn ->
with {:ok, qr_token} <- fetch_qr_token(token),
session_attrs = Map.put(device_attrs, :user_id, qr_token.user_id),
{:ok, session} <- create_mobile_session(session_attrs) do
# Mark QR token as completed
qr_token
|> QRLoginToken.complete_changeset(session.id)
|> Repo.update!()
{:ok, session}
else
{:error, :invalid_token} -> {:error, :invalid_token}
{:error, changeset} -> {:error, changeset}
end
end
end
# Helper function to unwrap nested transaction results
defp unwrap_transaction_result(result) do
case result do
{:ok, {:ok, value}} -> {:ok, value}
{:ok, {:error, reason}} -> {:error, reason}
{:error, reason} -> {:error, reason}
end
end
defp fetch_qr_token(token) do
case get_qr_login_token(token) do
nil -> {:error, :invalid_token}
qr_token -> {:ok, qr_token}
end
end
@doc """
Deletes expired QR login tokens.
Returns the number of tokens deleted.
"""
def delete_expired_qr_tokens do
now = DateTime.utc_now()
{count, _} =
QRLoginToken
|> where([t], t.expires_at <= ^now)
|> Repo.delete_all()
count
end
@doc """
Checks if a QR login token has been completed.
Returns the mobile session if completed, nil otherwise.
"""
def check_qr_login_completed(token) when is_binary(token) do
QRLoginToken
|> where([t], t.token == ^token)
|> where([t], not is_nil(t.completed_at))
|> preload(:mobile_session)
|> Repo.one()
|> case do
%QRLoginToken{mobile_session: session} when not is_nil(session) -> session
_ -> nil
end
end
def check_qr_login_completed(_), do: nil
end