defmodule PhoenixKit.Modules.Publishing.ActivityLog do @moduledoc false # Activity-logging helper for Publishing mutations. # # Wraps `PhoenixKit.Activity.log/1` with the `"publishing"` module key # injected and guards with `Code.ensure_loaded?/1` so the module stays # usable in environments where the Activity context isn't available # (library tests, misconfigured parent apps). Any exception from the # Activity path is swallowed with a `Logger.warning` so audit failures # never crash the primary mutation. require Logger @module_key "publishing" @spec log(map()) :: :ok def log(attrs) when is_map(attrs) do if Code.ensure_loaded?(PhoenixKit.Activity) do try do PhoenixKit.Activity.log(Map.put(attrs, :module, @module_key)) rescue Postgrex.Error -> # phoenix_kit_activities table may be missing in test sandboxes # or hosts that haven't run the Activity migration. Silent — # we don't want to spam Logger on every mutation. :ok DBConnection.OwnershipError -> # Test process not allowed on sandbox connection (background # task / async PubSub broadcast crossing into a logging path). # The primary mutation already succeeded; audit failure is # acceptable here. Silent for the same reason as Postgrex.Error. :ok error -> Logger.warning( "PhoenixKit.Modules.Publishing activity log failed: " <> "#{Exception.message(error)} — " <> "attrs=#{inspect(Map.take(attrs, [:action, :resource_type, :resource_uuid]))}" ) catch :exit, _reason -> # Sandbox connection lost mid-call. Same rationale as # rescue clauses above — primary write already done, audit # row drop is acceptable. :ok end end :ok end @doc """ Convenience for the standard "user-driven mutation" shape — wraps `log/1` with `mode: "manual"` and the canonical key set so context functions only pass the bits that vary. """ @spec log_manual(String.t(), String.t() | nil, String.t(), String.t() | nil, map()) :: :ok def log_manual(action, actor_uuid, resource_type, resource_uuid, metadata \\ %{}) do log(%{ action: action, mode: "manual", actor_uuid: actor_uuid, resource_type: resource_type, resource_uuid: resource_uuid, metadata: metadata }) end @doc """ Extracts `:actor_uuid` from an opts keyword list or map. Returns `nil` for anything else. Designed to be the single point where context functions read the caller's user identity — keeps the call sites short. """ @spec actor_uuid(keyword() | map() | nil) :: String.t() | nil def actor_uuid(opts) when is_list(opts), do: Keyword.get(opts, :actor_uuid) def actor_uuid(opts) when is_map(opts), do: Map.get(opts, :actor_uuid) def actor_uuid(_), do: nil @doc """ Logs a failed user-driven mutation with `db_pending: true` so the audit trail still captures the user-initiated action when the primary write failed (DB constraint, sandbox crash, etc). `resource_uuid` is `nil` when the failure happened before a row was assigned a UUID — that's expected for create paths. Metadata callers pass should still be PII-safe (slugs / names / status, never email or free-text body). """ @spec log_failed_mutation( String.t(), String.t() | nil, String.t(), String.t() | nil, map() ) :: :ok def log_failed_mutation(action, actor_uuid, resource_type, resource_uuid, metadata \\ %{}) do log(%{ action: action, mode: "manual", actor_uuid: actor_uuid, resource_type: resource_type, resource_uuid: resource_uuid, metadata: Map.put(metadata, "db_pending", true) }) end end