feat: expose CRUD, onboarding, pubsub via web

This commit is contained in:
2026-04-22 16:32:41 +02:00
parent 9807331da4
commit 9389c32244
33 changed files with 1536 additions and 7 deletions
@@ -0,0 +1,113 @@
defmodule Localiser.Web.Controllers.FloorController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Floors
alias Localiser.Web.Schemas
tags ["Floors"]
security [%{"bearerAuth" => []}]
operation :index,
summary: "List all floors",
responses: [
ok: {"Floor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Floor}},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :show,
summary: "Get a floor",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Floor", "application/json", Schemas.Floor},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :create,
summary: "Create a floor",
request_body: {"Floor params", "application/json", Schemas.FloorParams, required: true},
responses: [
created: {"Created floor", "application/json", Schemas.Floor},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :update,
summary: "Update a floor",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Floor params", "application/json", Schemas.FloorUpdateParams},
responses: [
ok: {"Updated floor", "application/json", Schemas.Floor},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :delete,
summary: "Delete a floor (also deletes all rooms and unenrols sensors)",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
no_content: "Deleted",
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
def index(conn, _params) do
json(conn, Enum.map(Floors.list_floors(), &render_floor/1))
end
def show(conn, %{"id" => id}) do
floor = Floors.get_floor!(id)
json(conn, render_floor(floor))
end
def create(conn, params) do
case Floors.create_floor(params) do
{:ok, floor} ->
conn
|> put_status(:created)
|> json(render_floor(floor))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def update(conn, %{"id" => id} = params) do
floor = Floors.get_floor!(id)
attrs = Map.drop(params, ["id"])
case Floors.update_floor(floor, attrs) do
{:ok, updated} ->
json(conn, render_floor(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def delete(conn, %{"id" => id}) do
floor = Floors.get_floor!(id)
{:ok, _} = Floors.delete_floor(floor)
send_resp(conn, :no_content, "")
end
defp render_floor(floor) do
%{
id: floor.id,
name: floor.name,
width: floor.width,
height: floor.height
}
end
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, val}, acc ->
String.replace(acc, "%{#{key}}", to_string(val))
end)
end)
end
end
@@ -0,0 +1,20 @@
defmodule Localiser.Web.Controllers.OnboardingController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.System
alias Localiser.Web.Schemas
tags ["Onboarding"]
security []
operation :status,
summary: "Get onboarding checklist status",
responses: [
ok: {"Onboarding status", "application/json", Schemas.OnboardingStatus}
]
def status(conn, _params) do
json(conn, System.onboarding_status())
end
end
@@ -0,0 +1,130 @@
defmodule Localiser.Web.Controllers.RoomController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Rooms
alias Localiser.Web.Schemas
tags ["Rooms"]
security [%{"bearerAuth" => []}]
operation :index,
summary: "List rooms for a floor",
parameters: [floor_id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Room list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Room}},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :show,
summary: "Get a room",
parameters: [
floor_id: [in: :path, type: :integer, required: true],
id: [in: :path, type: :integer, required: true]
],
responses: [
ok: {"Room", "application/json", Schemas.Room},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :create,
summary: "Create a room",
parameters: [floor_id: [in: :path, type: :integer, required: true]],
request_body: {"Room params", "application/json", Schemas.RoomParams, required: true},
responses: [
created: {"Created room", "application/json", Schemas.Room},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :update,
summary: "Update a room",
parameters: [
floor_id: [in: :path, type: :integer, required: true],
id: [in: :path, type: :integer, required: true]
],
request_body: {"Room params", "application/json", Schemas.RoomUpdateParams},
responses: [
ok: {"Updated room", "application/json", Schemas.Room},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :delete,
summary: "Delete a room (also unenrols sensors)",
parameters: [
floor_id: [in: :path, type: :integer, required: true],
id: [in: :path, type: :integer, required: true]
],
responses: [
no_content: "Deleted",
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
def index(conn, %{"floor_id" => floor_id}) do
rooms = Rooms.list_rooms_for_floor(floor_id)
json(conn, Enum.map(rooms, &render_room/1))
end
def show(conn, %{"id" => id}) do
room = Rooms.get_room!(id)
json(conn, render_room(room))
end
def create(conn, %{"floor_id" => floor_id} = params) do
attrs = Map.put(params, "floor_id", floor_id)
case Rooms.create_room(attrs) do
{:ok, room} ->
conn
|> put_status(:created)
|> json(render_room(room))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def update(conn, %{"id" => id} = params) do
room = Rooms.get_room!(id)
attrs = Map.drop(params, ["id", "floor_id"])
case Rooms.update_room(room, attrs) do
{:ok, updated} ->
json(conn, render_room(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def delete(conn, %{"id" => id}) do
room = Rooms.get_room!(id)
{:ok, _} = Rooms.delete_room(room)
send_resp(conn, :no_content, "")
end
defp render_room(room) do
%{
id: room.id,
name: room.name,
floor_id: room.floor_id,
x: room.x,
y: room.y,
width: room.width,
height: room.height
}
end
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, val}, acc ->
String.replace(acc, "%{#{key}}", to_string(val))
end)
end)
end
end
@@ -0,0 +1,193 @@
defmodule Localiser.Web.Controllers.SensorController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Sensors
alias Localiser.Localisation.Sensor.Server, as: SensorServer
alias Localiser.Web.Schemas
tags ["Sensors"]
security [%{"bearerAuth" => []}]
operation :index,
summary: "List all sensors",
responses: [
ok: {"Sensor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Sensor}},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :unplaced,
summary: "List sensors not yet assigned to a room",
responses: [
ok: {"Sensor list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Sensor}},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :show,
summary: "Get a sensor",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Sensor", "application/json", Schemas.Sensor},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :update,
summary: "Update sensor metadata",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Sensor params", "application/json", Schemas.SensorUpdateParams},
responses: [
ok: {"Updated sensor", "application/json", Schemas.Sensor},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :delete,
summary: "Delete / unenrol a sensor",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
no_content: "Deleted",
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :place,
summary: "Place sensor at a position in a room",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Place params", "application/json", Schemas.SensorPlaceParams, required: true},
responses: [
ok: {"Updated sensor", "application/json", Schemas.Sensor},
bad_request: {"Missing params", "application/json", Schemas.Error},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :unplace,
summary: "Remove sensor from floor layout",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Updated sensor", "application/json", Schemas.Sensor},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_start,
summary: "Begin RSSI calibration",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Calibration params", "application/json", Schemas.CalibrationStartParams, required: true},
responses: [
ok: {"Calibration started", "application/json", Schemas.CalibrationStatus},
bad_request: {"Missing reference_distance", "application/json", Schemas.Error},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_stop,
summary: "Abort active calibration",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Calibration aborted", "application/json", Schemas.CalibrationStatus},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
def index(conn, _params) do
json(conn, Enum.map(Sensors.list_sensors(), &render_sensor/1))
end
def unplaced(conn, _params) do
json(conn, Enum.map(Sensors.list_unplaced(), &render_sensor/1))
end
def show(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
json(conn, render_sensor(sensor))
end
def update(conn, %{"id" => id} = params) do
sensor = Sensors.get_sensor!(id)
attrs = Map.drop(params, ["id"])
case Sensors.update_sensor(sensor, attrs) do
{:ok, updated} ->
json(conn, render_sensor(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def delete(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
{:ok, _} = Sensors.delete_sensor(sensor)
send_resp(conn, :no_content, "")
end
def place(conn, %{"id" => id, "room_id" => room_id, "x" => x, "y" => y}) do
sensor = Sensors.get_sensor!(id)
case Sensors.place_sensor(sensor, room_id, {x, y}) do
{:ok, updated} ->
json(conn, render_sensor(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def place(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "room_id, x, and y are required"})
end
def unplace(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
case Sensors.remove_from_layout(sensor) do
{:ok, updated} ->
json(conn, render_sensor(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def calibration_start(conn, %{"id" => id, "reference_distance" => ref_dist}) do
sensor = Sensors.get_sensor!(id)
:ok = SensorServer.begin_calibration(sensor.sensor_id, ref_dist)
json(conn, %{status: "calibrating"})
end
def calibration_start(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "reference_distance is required"})
end
def calibration_stop(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
:ok = SensorServer.abort_calibration(sensor.sensor_id)
json(conn, %{status: "idle"})
end
defp render_sensor(sensor) do
%{
id: sensor.id,
sensor_id: sensor.sensor_id,
room_id: sensor.room_id,
floor_x: sensor.floor_x,
floor_y: sensor.floor_y,
rssi_ref: sensor.rssi_ref
}
end
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, val}, acc ->
String.replace(acc, "%{#{key}}", to_string(val))
end)
end)
end
end
@@ -0,0 +1,52 @@
defmodule Localiser.Web.Controllers.SessionController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Users
alias Localiser.Web.Token
alias Localiser.Web.Schemas
tags ["Auth"]
security []
operation :create,
summary: "Log in and receive a JWT",
request_body: {"Login params", "application/json", Schemas.SessionParams, required: true},
responses: [
ok: {"JWT + user", "application/json", Schemas.TokenResponse},
bad_request: {"Missing username/password", "application/json", Schemas.Error},
unauthorized: {"Invalid credentials", "application/json", Schemas.Error}
]
operation :delete,
summary: "Log out (client discards token)",
responses: [no_content: "Logged out"]
def create(conn, %{"username" => username, "password" => password}) do
case Users.authenticate_user(username, password) do
{:ok, user} ->
token = Token.generate(%{"sub" => user.id})
json(conn, %{token: token, user: render_user(user)})
{:error, :invalid_credentials} ->
conn
|> put_status(:unauthorized)
|> json(%{error: "invalid credentials"})
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "username and password are required"})
end
def delete(conn, _params) do
# JWT is stateless; client discards token
send_resp(conn, :no_content, "")
end
defp render_user(user) do
%{id: user.id, username: user.username, is_admin: user.is_admin}
end
end
@@ -0,0 +1,57 @@
defmodule Localiser.Web.Controllers.SetupController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Users
alias Localiser.Web.Token
alias Localiser.Web.Schemas
tags ["Auth"]
operation :create,
summary: "Create first admin user",
description: "Only succeeds when no users exist. Blocked with 403 afterwards.",
security: [],
request_body: {"Setup params", "application/json", Schemas.SetupParams, required: true},
responses: [
created: {"Admin user created", "application/json", Schemas.TokenResponse},
bad_request: {"Missing username/password", "application/json", Schemas.Error},
forbidden: {"System already initialised", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
def create(conn, %{"username" => username, "password" => password}) do
attrs = %{"username" => username, "password" => password, "is_admin" => true}
case Users.create_user(attrs) do
{:ok, user} ->
token = Token.generate(%{"sub" => user.id})
conn
|> put_status(:created)
|> json(%{token: token, user: render_user(user)})
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "username and password are required"})
end
defp render_user(user) do
%{id: user.id, username: user.username, is_admin: user.is_admin}
end
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, val}, acc ->
String.replace(acc, "%{#{key}}", to_string(val))
end)
end)
end
end
@@ -0,0 +1,112 @@
defmodule Localiser.Web.Controllers.TagController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Tags
alias Localiser.Web.Schemas
tags ["Tags"]
security [%{"bearerAuth" => []}]
operation :index,
summary: "List all tags",
responses: [
ok: {"Tag list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.Tag}},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :show,
summary: "Get a tag",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Tag", "application/json", Schemas.Tag},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :create,
summary: "Enroll a tag",
request_body: {"Tag params", "application/json", Schemas.TagParams, required: true},
responses: [
created: {"Enrolled tag", "application/json", Schemas.Tag},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :update,
summary: "Update a tag",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Tag params", "application/json", Schemas.TagUpdateParams},
responses: [
ok: {"Updated tag", "application/json", Schemas.Tag},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :delete,
summary: "Delete / unenroll a tag",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
no_content: "Deleted",
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
def index(conn, _params) do
json(conn, Enum.map(Tags.list_tags(), &render_tag/1))
end
def show(conn, %{"id" => id}) do
tag = Tags.get_tag!(id)
json(conn, render_tag(tag))
end
def create(conn, params) do
case Tags.create_tag(params) do
{:ok, tag} ->
conn
|> put_status(:created)
|> json(render_tag(tag))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def update(conn, %{"id" => id} = params) do
tag = Tags.get_tag!(id)
attrs = Map.drop(params, ["id"])
case Tags.update_tag(tag, attrs) do
{:ok, updated} ->
json(conn, render_tag(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def delete(conn, %{"id" => id}) do
tag = Tags.get_tag!(id)
{:ok, _} = Tags.delete_tag(tag)
send_resp(conn, :no_content, "")
end
defp render_tag(tag) do
%{
id: tag.id,
tag_id: tag.tag_id,
name: tag.name
}
end
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, val}, acc ->
String.replace(acc, "%{#{key}}", to_string(val))
end)
end)
end
end
@@ -0,0 +1,147 @@
defmodule Localiser.Web.Controllers.UserController do
use Phoenix.Controller, formats: [:json]
use OpenApiSpex.ControllerSpecs
alias Localiser.Domain.Users
alias Localiser.Web.Schemas
tags ["Users"]
security [%{"bearerAuth" => []}]
operation :me,
summary: "Get own profile",
responses: [
ok: {"Current user", "application/json", Schemas.User},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :index,
summary: "List all users (admin)",
responses: [
ok: {"User list", "application/json", %OpenApiSpex.Schema{type: :array, items: Schemas.User}},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
forbidden: {"Forbidden", "application/json", Schemas.Error}
]
operation :show,
summary: "Get a user (admin)",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"User", "application/json", Schemas.User},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
forbidden: {"Forbidden", "application/json", Schemas.Error}
]
operation :create,
summary: "Create a user (admin)",
request_body: {"User params", "application/json", Schemas.UserCreateParams, required: true},
responses: [
created: {"Created user", "application/json", Schemas.User},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
forbidden: {"Forbidden", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :update,
summary: "Update a user (admin)",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"User params", "application/json", Schemas.UserUpdateParams},
responses: [
ok: {"Updated user", "application/json", Schemas.User},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
forbidden: {"Forbidden", "application/json", Schemas.Error},
unprocessable_entity: {"Validation errors", "application/json", Schemas.ValidationErrors}
]
operation :delete,
summary: "Delete a user (admin)",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
no_content: "Deleted",
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
forbidden: {"Forbidden", "application/json", Schemas.Error}
]
operation :promote,
summary: "Promote a user to admin",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Updated user", "application/json", Schemas.User},
unauthorized: {"Unauthorized", "application/json", Schemas.Error},
forbidden: {"Forbidden", "application/json", Schemas.Error}
]
def me(conn, _params) do
json(conn, render_user(conn.assigns.current_user))
end
def index(conn, _params) do
json(conn, Enum.map(Users.list_users(), &render_user/1))
end
def show(conn, %{"id" => id}) do
user = Users.get_user!(id)
json(conn, render_user(user))
end
def create(conn, params) do
case Users.create_user(params) do
{:ok, user} ->
conn
|> put_status(:created)
|> json(render_user(user))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def update(conn, %{"id" => id} = params) do
user = Users.get_user!(id)
attrs = Map.drop(params, ["id"])
case Users.update_user(user, attrs) do
{:ok, updated} ->
json(conn, render_user(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
def delete(conn, %{"id" => id}) do
user = Users.get_user!(id)
{:ok, _} = Users.delete_user(user)
send_resp(conn, :no_content, "")
end
def promote(conn, %{"id" => id}) do
user = Users.get_user!(id)
case Users.promote_to_admin(user) do
{:ok, updated} ->
json(conn, render_user(updated))
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
defp render_user(user) do
%{id: user.id, username: user.username, is_admin: user.is_admin}
end
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, val}, acc ->
String.replace(acc, "%{#{key}}", to_string(val))
end)
end)
end
end