lib/openagents/projects/project_note.ex

main at 58e6347eeb72 · 4 KB

defmodule OpenAgents.Projects.ProjectNote do
  @moduledoc """
  One entry in a project's discussion and activity record.

  A note carries project-wide context: why the project exists, the operating
  assumptions in force, and the decisions that apply across several issues.
  Issue comments stay on issues.

  Two kinds share the table and the ordering:

    * `"note"` is discussion an operator writes. Its author can edit and delete
      it.
    * `"activity"` is the immutable record of a project change, written by the
      context that made the change. Nothing edits or deletes it.

  The `repository_id` repeats the owning project's repository so the row is
  filtered through the same authority boundary every other project surface
  reads through, and a database constraint keeps the pair in agreement.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias OpenAgents.ContentVault
  alias OpenAgents.Projects.Project
  alias OpenAgents.Repositories.Repository

  @kinds ["note", "activity"]
  @scope "project_notes.body"

  @doc "The note kinds, discussion first."
  def kinds, do: @kinds

  schema "project_notes" do
    field :body, :string, redact: true
    field :body_ciphertext, :binary, redact: true
    field :kind, :string, default: "note"
    field :author, :map

    belongs_to :project, Project
    belongs_to :repository, Repository, type: :binary_id
    belongs_to :author_user, OpenAgents.Accounts.User, type: :binary_id

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(note, attrs) do
    note
    |> cast(attrs, [:body, :kind, :author, :project_id, :repository_id, :author_user_id])
    |> update_change(:body, &trim/1)
    |> validate_required([:kind, :project_id, :repository_id])
    |> validate_body_present()
    |> validate_inclusion(:kind, @kinds)
    |> validate_length(:body, max: 20_000)
    |> seal_body()
    |> foreign_key_constraint(:project_id)
    |> foreign_key_constraint(:repository_id)
    |> foreign_key_constraint(:author_user_id)
    |> foreign_key_constraint(:project_id, name: :project_notes_project_repository_fkey)
    |> check_constraint(:kind, name: :project_notes_kind_check)
  end

  @doc "The column this schema's sealed body belongs to."
  @spec scope() :: String.t()
  def scope, do: @scope

  @doc """
  The note body, opened from the seal.

  Falls back to the plaintext column for a row an un-replaced node wrote during
  a rolling replacement, and returns `nil` when neither can be read so a
  project page renders one missing note rather than failing whole.
  """
  @spec text(%__MODULE__{}) :: String.t() | nil
  def text(%__MODULE__{body_ciphertext: sealed} = note) when is_binary(sealed),
    do: ContentVault.text(sealed, @scope, seal_binding(note))

  def text(%__MODULE__{body: body}), do: body

  @doc """
  The row identity a sealed body is bound to.

  The primary key is a sequence, so it does not exist while the insert is
  being built. What does exist, and never changes afterwards, is the note's
  place: an edit rewrites the body, never the project, the repository, or the
  kind. Binding to those is what stops a sealed note from being lifted into
  another project and still opening.
  """
  @spec seal_binding(%__MODULE__{}) :: ContentVault.binding()
  def seal_binding(%__MODULE__{} = note), do: [note.project_id, note.repository_id, note.kind]

  # An edit that changes only the kind or the author leaves `body` unchanged,
  # and the stored plaintext is now `nil` because the body rests sealed. So
  # "a note has a body" is asked of both halves rather than of the column the
  # contract migration is going to drop.
  defp validate_body_present(changeset) do
    if get_field(changeset, :body) || get_field(changeset, :body_ciphertext),
      do: changeset,
      else: add_error(changeset, :body, "can't be blank", validation: :required)
  end

  defp seal_body(%Ecto.Changeset{valid?: true} = changeset) do
    case get_change(changeset, :body) do
      nil ->
        changeset

      body ->
        binding = [
          get_field(changeset, :project_id),
          get_field(changeset, :repository_id),
          get_field(changeset, :kind)
        ]

        case ContentVault.seal(body, @scope, binding) do
          {:ok, sealed} ->
            changeset
            |> put_change(:body_ciphertext, sealed)
            |> force_change(:body, nil)

          {:error, reason} ->
            add_error(changeset, :body, "cannot be sealed", reason: reason)
        end
    end
  end

  defp seal_body(changeset), do: changeset

  defp trim(body) when is_binary(body), do: String.trim(body)
  defp trim(body), do: body
end