feat: closing last gaps in backend functions we have available

This commit is contained in:
2026-04-24 12:15:56 +02:00
parent 13ace08a41
commit f96759ab2f
16 changed files with 610 additions and 62 deletions

24
lib/bds/mcp/proposal.ex Normal file
View File

@@ -0,0 +1,24 @@
defmodule BDS.MCP.Proposal do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :string, autogenerate: false}
schema "mcp_proposals" do
field :kind, :string
field :status, Ecto.Enum, values: [:pending, :accepted, :discarded, :expired], default: :pending
field :entity_id, :string
field :data, :map
field :created_at, :integer
field :expires_at, :integer
end
def changeset(proposal, attrs) do
proposal
|> cast(attrs, [:id, :kind, :status, :entity_id, :data, :created_at, :expires_at], empty_values: [nil])
|> validate_required([:id, :kind, :status, :entity_id, :data, :created_at, :expires_at])
|> unique_constraint(:status, name: :mcp_proposals_entity_idx)
end
end

View File

@@ -1,51 +1,45 @@
defmodule BDS.MCP.ProposalStore do
@moduledoc false
use Agent
import Ecto.Query
alias BDS.MCP.Proposal
alias BDS.Persistence
alias BDS.Repo
@default_ttl_ms 30 * 60 * 1000
def ensure_started do
case Process.whereis(__MODULE__) do
nil ->
case Agent.start_link(fn -> %{} end, name: __MODULE__) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
{:error, reason} -> {:error, reason}
end
_pid ->
:ok
end
end
def ensure_started, do: :ok
def create(kind, data, opts \\ []) when is_binary(kind) and is_map(data) do
:ok = ensure_started()
cleanup_expired(opts)
now = Persistence.now_ms()
proposal = %{
entity_id = Keyword.get(opts, :entity_id) || derive_entity_id(data)
%Proposal{}
|> Proposal.changeset(%{
id: Ecto.UUID.generate(),
kind: kind,
status: :pending,
entity_id: entity_id,
data: data,
created_at: Persistence.now_ms(),
expires_at: Persistence.now_ms() + Keyword.get(opts, :ttl_ms, @default_ttl_ms)
}
Agent.update(__MODULE__, &Map.put(&1, proposal.id, proposal))
proposal
created_at: now,
expires_at: now + Keyword.get(opts, :ttl_ms, @default_ttl_ms)
})
|> Repo.insert!()
end
def get(id) when is_binary(id) do
:ok = ensure_started()
cleanup_expired([])
Agent.get(__MODULE__, &Map.get(&1, id))
Repo.get(Proposal, id)
end
def remove(id) when is_binary(id) do
:ok = ensure_started()
Agent.update(__MODULE__, &Map.delete(&1, id))
Repo.delete_all(from proposal in Proposal, where: proposal.id == ^id)
:ok
end
@@ -53,26 +47,49 @@ defmodule BDS.MCP.ProposalStore do
:ok = ensure_started()
cleanup_expired([])
Agent.get(__MODULE__, fn proposals ->
proposals
|> Map.values()
|> Enum.sort_by(& &1.created_at)
end)
Repo.all(from proposal in Proposal, order_by: [asc: proposal.created_at])
end
def cleanup_expired(opts) do
def cleanup_expired(opts \\ []) do
:ok = ensure_started()
now = Persistence.now_ms()
on_expire = Keyword.get(opts, :on_expire)
Agent.get_and_update(__MODULE__, fn proposals ->
{expired, active} = Enum.split_with(proposals, fn {_id, proposal} -> proposal.expires_at <= now end)
expired =
Repo.all(
from proposal in Proposal,
where: proposal.status == :pending and proposal.expires_at <= ^now
)
Enum.each(expired, fn {_id, proposal} ->
if is_function(on_expire, 1), do: on_expire.(proposal)
end)
{Enum.map(expired, &elem(&1, 1)), Map.new(active)}
Enum.each(expired, fn proposal ->
if is_function(on_expire, 1), do: on_expire.(proposal)
mark_status(proposal.id, :expired)
end)
Enum.map(expired, &Repo.get(Proposal, &1.id))
end
def mark_accepted(id) when is_binary(id), do: mark_status(id, :accepted)
def mark_discarded(id) when is_binary(id), do: mark_status(id, :discarded)
defp mark_status(id, status) do
case Repo.get(Proposal, id) do
nil -> nil
proposal ->
Repo.delete_all(
from other in Proposal,
where:
other.id != ^id and other.kind == ^proposal.kind and other.entity_id == ^proposal.entity_id and
other.status == ^status
)
proposal
|> Proposal.changeset(%{status: status})
|> Repo.update!()
end
end
defp derive_entity_id(data) do
data["post_id"] || data["script_id"] || data["template_id"] || data["media_id"] || Ecto.UUID.generate()
end
end