defmodule AshPaperTrail.Resource.Changes.CreateNewVersion do
@moduledoc "Creates a new version whenever a resource is created, deleted, or updated"
use Ash.Resource.Change
require Ash.Query
require Logger
@impl true
def change(changeset, _, _) do
if changeset.action.name not in AshPaperTrail.Resource.Info.ignore_actions(changeset.resource) &&
(changeset.action_type in [:create, :destroy] ||
(changeset.action_type == :update &&
changeset.action.name in AshPaperTrail.Resource.Info.on_actions(changeset.resource))) do
create_new_version(changeset)
else
changeset
end
end
@impl true
def atomic(changeset, opts, context) do
change_tracking_mode = AshPaperTrail.Resource.Info.change_tracking_mode(changeset.resource)
if change_tracking_mode == :full_diff do
{:not_atomic, "Cannot perform full_diff change tracking with AshPaperTrail atomically."}
else
{:ok, change(changeset, opts, context)}
end
end
defp create_new_version(changeset) do
Ash.Changeset.after_action(changeset, fn changeset, result ->
if changeset.action_type in [:create, :destroy] ||
(changeset.action_type == :update && changeset.context.changed?) do
{:ok, result, build_notifications(changeset, result)}
else
{:ok, result}
end
end)
end
defp build_notifications(changeset, result) do
version_resource = AshPaperTrail.Resource.Info.version_resource(changeset.resource)
version_resource_attributes =
version_resource |> Ash.Resource.Info.attributes() |> Enum.map(& &1.name)
version_changeset = Ash.Changeset.new(version_resource)
to_skip =
Ash.Resource.Info.primary_key(changeset.resource) ++
AshPaperTrail.Resource.Info.ignore_attributes(changeset.resource)
attributes_as_attributes =
AshPaperTrail.Resource.Info.attributes_as_attributes(changeset.resource)
change_tracking_mode = AshPaperTrail.Resource.Info.change_tracking_mode(changeset.resource)
belongs_to_actors =
AshPaperTrail.Resource.Info.belongs_to_actor(changeset.resource)
actor = changeset.context[:private][:actor]
sensitive_mode =
changeset.context[:sensitive_attributes] ||
AshPaperTrail.Resource.Info.sensitive_attributes(changeset.resource)
resource_attributes =
changeset.resource
|> Ash.Resource.Info.attributes()
|> Map.new(&{&1.name, &1})
input =
version_resource_attributes
|> Enum.filter(&(&1 in attributes_as_attributes))
|> Enum.reject(&(resource_attributes[&1].sensitive? and sensitive_mode != :display))
|> Map.new(&{&1, Map.get(result, &1)})
changes =
resource_attributes
|> Map.drop(to_skip)
|> Map.values()
|> build_changes(change_tracking_mode, changeset, result)
|> maybe_redact_changes(resource_attributes, sensitive_mode)
input =
Enum.reduce(belongs_to_actors, input, fn belongs_to_actor, input ->
with true <- is_struct(actor) && actor.__struct__ == belongs_to_actor.destination,
relationship when not is_nil(relationship) <-
Ash.Resource.Info.relationship(version_resource, belongs_to_actor.name) do
primary_key = Map.get(actor, hd(Ash.Resource.Info.primary_key(actor.__struct__)))
source_attribute = Map.get(relationship, :source_attribute)
Map.put(input, source_attribute, primary_key)
else
_ ->
input
end
end)
|> Map.merge(%{
version_source_id: Map.get(result, hd(Ash.Resource.Info.primary_key(changeset.resource))),
version_action_type: changeset.action.type,
version_action_name: changeset.action.name,
version_resource_identifier:
AshPaperTrail.Resource.Info.resource_identifier(changeset.resource),
changes: changes
})
{_, notifications} =
version_changeset
|> Ash.Changeset.set_context(%{ash_paper_trail?: true})
|> Ash.Changeset.for_create(:create, input,
tenant: changeset.tenant,
authorize?: authorize?(changeset.domain),
actor: actor,
domain: changeset.domain,
skip_unknown_inputs: Map.keys(input)
)
|> Ash.create!(return_notifications?: true)
notifications
end
defp build_changes(attributes, :changes_only, changeset, result) do
AshPaperTrail.ChangeBuilders.ChangesOnly.build_changes(attributes, changeset, result)
end
defp build_changes(attributes, :snapshot, changeset, result) do
AshPaperTrail.ChangeBuilders.Snapshot.build_changes(attributes, changeset, result)
end
defp build_changes(attributes, :full_diff, changeset, result) do
AshPaperTrail.ChangeBuilders.FullDiff.build_changes(attributes, changeset, result)
end
defp authorize?(domain), do: Ash.Domain.Info.authorize(domain) == :always
defp maybe_redact_changes(changes, _, :display), do: changes
defp maybe_redact_changes(changes, attributes, :redact) do
attributes
|> Map.values()
|> Enum.filter(& &1.sensitive?)
|> Enum.reduce(changes, fn attribute, changes ->
Map.put(changes, attribute.name, "REDACTED")
end)
end
defp maybe_redact_changes(changes, attributes, :ignore) do
sensitive_attributes =
attributes
|> Map.values()
|> Enum.filter(& &1.sensitive?)
|> Enum.map(& &1.name)
Map.drop(changes, sensitive_attributes)
end
end