defmodule AshDispatch.Resources.DeliveryReceipt.Base do @moduledoc """ Provides the base DSL for DeliveryReceipt resources. This module exports a `__using__` macro that consuming apps can use to create their own DeliveryReceipt resource with a user relationship, similar to how ash_paper_trail creates version resources. ## Usage defmodule MyApp.Deliveries.DeliveryReceipt do use AshDispatch.Resources.DeliveryReceipt.Base, repo: MyApp.Repo, user_resource: MyApp.Accounts.User end This will create a complete DeliveryReceipt resource with all attributes, actions, and a user relationship to your User resource - no manual duplication needed! """ defmacro __using__(opts) do repo = Keyword.fetch!(opts, :repo) user_resource = Keyword.get(opts, :user_resource) notification_resource = Keyword.get(opts, :notification_resource, AshDispatch.Resources.Notification) quote do use Ash.Resource, domain: unquote(Keyword.get(opts, :domain, AshDispatch.Domain)), data_layer: AshPostgres.DataLayer, authorizers: [Ash.Policy.Authorizer], extensions: [ AshStateMachine, AshTypescript.Resource ] postgres do table "delivery_receipts" repo(unquote(repo)) references do reference(:notification, on_delete: :nilify) # Add user reference if user_resource is configured if unquote(user_resource) do reference(:user, on_delete: :nilify) end end end state_machine do initial_states([:pending]) default_initial_state(:pending) state_attribute(:status) extra_states([:sending, :sent, :failed_permanent, :skipped, :scheduled, :failed]) transitions do transition(:schedule, from: :pending, to: :scheduled) transition(:mark_sending, from: [:scheduled, :pending], to: :sending) transition(:mark_sent, from: [:sending, :scheduled, :pending], to: :sent) transition(:mark_failed, from: [:sending, :scheduled, :pending], to: :failed) transition(:mark_failed_permanent, from: [:sending, :scheduled, :failed], to: :failed_permanent ) transition(:skip, from: [:pending, :scheduled, :sending], to: :skipped) transition(:retry, from: :failed, to: :scheduled) end end # Relationships relationships do belongs_to :notification, unquote(notification_resource) do source_attribute :notification_id destination_attribute :id allow_nil? true public? true define_attribute? false end # Add user relationship if user_resource is configured if unquote(user_resource) do belongs_to :user, unquote(user_resource) do source_attribute :user_id destination_attribute :id allow_nil? true public? true define_attribute? false end end end # All attributes attributes do uuid_primary_key :id attribute :event_id, :string, allow_nil?: false, public?: true attribute :transport, :atom, allow_nil?: false, public?: true, constraints: [one_of: [:email, :in_app, :discord, :sms, :webhook, :slack]] attribute :user_id, :uuid, allow_nil?: true, public?: true attribute :notification_id, :uuid, allow_nil?: true, public?: true attribute :audience, :atom, allow_nil?: false, public?: true, constraints: [one_of: [:user, :admin, :system]], description: "Target audience type" attribute :status, :atom, default: :pending, allow_nil?: false, public?: true, constraints: [ one_of: [:pending, :scheduled, :sending, :sent, :failed, :failed_permanent, :skipped] ] attribute :recipient, :string, allow_nil?: false, public?: true attribute :provider_id, :string, allow_nil?: true, public?: true, description: "Provider-specific message ID" attribute :provider_response, :map, default: %{}, allow_nil?: false, public?: true, description: "Response from delivery provider" attribute :subject, :string, allow_nil?: true, public?: true attribute :body_text, :string, allow_nil?: true, public?: true attribute :body_html, :string, allow_nil?: true, public?: true attribute :content, :map, default: %{}, allow_nil?: false, public?: true attribute :oban_job_id, :integer, allow_nil?: true, public?: true attribute :error_message, :string, allow_nil?: true, public?: true attribute :retry_count, :integer, default: 0, allow_nil?: false, public?: true attribute :last_retry_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "Timestamp of last retry attempt" attribute :sent_at, :utc_datetime_usec, allow_nil?: true, public?: true attribute :delivered_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "When provider confirmed delivery" attribute :delivery_delayed_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "When delivery was delayed by provider" attribute :failed_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "When delivery failed" attribute :opened_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "When email was opened" attribute :clicked_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "When link was clicked" attribute :bounced_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "When email bounced" attribute :complained_at, :utc_datetime_usec, allow_nil?: true, public?: true, description: "When spam complaint received" # Source resource linking (for navigation back to the source) attribute :source_type, :string, allow_nil?: true, public?: true, description: "Source resource module name" attribute :source_id, :uuid, allow_nil?: true, public?: true, description: "Source resource ID" timestamps(public?: true) end calculations do calculate :oban_job, :map, {AshDispatch.Calculations.ObanJob, []} do public? true description "Loaded Oban job data" end calculate :source_url, :string, {AshDispatch.Calculations.SourceUrl, []} do public? true description "URL path to the source resource (computed from event module)" allow_nil? true end calculate :source_label, :string, {AshDispatch.Calculations.SourceLabel, []} do public? true description "Human-readable label for the source resource type" allow_nil? true end calculate :admin_url, :string, {AshDispatch.Calculations.AdminUrl, []} do public? true description "Admin-specific URL path to the source resource" allow_nil? true end end actions do default_accept :* defaults [:read, :destroy] create :create do accept [ :event_id, :transport, :user_id, :notification_id, :audience, :recipient, :subject, :body_text, :body_html, :content, :oban_job_id, :provider_id, :provider_response, :source_type, :source_id ] end read :list_all do description "List all delivery receipts with filters (admin only)" argument :status, :atom, allow_nil?: true argument :transport, :atom, allow_nil?: true argument :event_id, :string, allow_nil?: true argument :audience, :atom, allow_nil?: true filter expr(if(is_nil(^arg(:status)), true, ^ref(:status) == ^arg(:status))) filter expr(if(is_nil(^arg(:transport)), true, ^ref(:transport) == ^arg(:transport))) filter expr(if(is_nil(^arg(:event_id)), true, ^ref(:event_id) == ^arg(:event_id))) filter expr(if(is_nil(^arg(:audience)), true, ^ref(:audience) == ^arg(:audience))) pagination offset?: true, keyset?: true, default_limit: 50 prepare build(sort: [inserted_at: :desc]) prepare {AshDispatch.Preparations.LoadObanJob, []} end read :list_for_user do argument :user_id, :uuid, allow_nil?: false argument :status, :atom, allow_nil?: true argument :transport, :atom, allow_nil?: true argument :event_id, :string, allow_nil?: true filter expr(^ref(:user_id) == ^arg(:user_id)) filter expr(if(is_nil(^arg(:status)), true, ^ref(:status) == ^arg(:status))) filter expr(if(is_nil(^arg(:transport)), true, ^ref(:transport) == ^arg(:transport))) filter expr(if(is_nil(^arg(:event_id)), true, ^ref(:event_id) == ^arg(:event_id))) pagination offset?: true, keyset?: true, default_limit: 20 prepare build(sort: [inserted_at: :desc]) end read :get do argument :id, :uuid, allow_nil?: false get? true filter expr(^ref(:id) == ^arg(:id)) prepare build(load: [:notification]) prepare {AshDispatch.Preparations.LoadObanJob, []} end read :get_by_provider_id do description "Find delivery receipt by provider ID (for webhook lookups)" argument :provider_id, :string, allow_nil?: false get? true filter expr(^ref(:provider_id) == ^arg(:provider_id)) prepare build(load: [:notification]) end update :update do primary? true accept [ :oban_job_id, :provider_response, :error_message, :retry_count, :last_retry_at, :notification_id ] end update :schedule do accept [:oban_job_id, :notification_id] change transition_state(:scheduled) end update :mark_sending do change transition_state(:sending) end update :mark_sent do require_atomic? false accept [:provider_id, :provider_response, :notification_id] change transition_state(:sent) change fn changeset, _ -> Ash.Changeset.change_attribute(changeset, :sent_at, DateTime.utc_now()) end end update :mark_failed do require_atomic? false accept [:error_message, :provider_response] change transition_state(:failed) change fn changeset, _ -> retry_count = Ash.Changeset.get_attribute(changeset, :retry_count) || 0 changeset |> Ash.Changeset.change_attribute(:retry_count, retry_count + 1) |> Ash.Changeset.change_attribute(:last_retry_at, DateTime.utc_now()) end end update :mark_failed_permanent do accept [:error_message, :provider_response] change transition_state(:failed_permanent) end update :skip do accept [:error_message] change transition_state(:skipped) end update :retry do require_atomic? false accept [:oban_job_id] validate {AshDispatch.Validations.ValidateCanRetry, []} change transition_state(:scheduled) change fn changeset, _ -> retry_count = Ash.Changeset.get_attribute(changeset, :retry_count) || 0 changeset |> Ash.Changeset.change_attribute(:retry_count, retry_count + 1) |> Ash.Changeset.change_attribute(:last_retry_at, DateTime.utc_now()) end end update :record_webhook_event do description "Record email event from provider webhook (delivery lifecycle and engagement)" accept [ :sent_at, :delivered_at, :delivery_delayed_at, :failed_at, :opened_at, :clicked_at, :bounced_at, :complained_at, :provider_response ] end end policies do # Bypass all policies for create/update/destroy (for workers, tests) bypass action_type([:create, :update, :destroy]) do authorize_if always() end # Read policies - only admins with configured permission can read # Configure permission checker: config :ash_dispatch, permission_checker: MyApp.PolicyHelpers.HasPermission policy action_type(:read) do authorize_if {AshDispatch.PolicyChecks.HasPermission, permission: :manage_delivery_receipts} forbid_if always() end end identities do identity :oban_job, [:oban_job_id] end end end end