cover/Elixir.WalletNotifications.Notification.html

1 defmodule WalletNotifications.Notification do
2 @moduledoc """
3 Notification struct representing a user-directed message.
4
5 Channels: :email | :sms | :push | :in_app
6 Types: :transaction_alert | :security_alert | :promotional | :system | :otp
7
8 Lifecycle:
9 :queued -> :sending -> :sent | :failed
10 :failed -> :queued (retry re-queue)
11
12 Terminal: :sent, :permanently_failed (max retries exceeded)
13
14 TypedId prefix: `ntf_`
15 """
16
17 alias WalletSharedKernel.TypedId
18
19 @channels [:email, :sms, :push, :in_app]
20 @types [:transaction_alert, :security_alert, :promotional, :system, :otp]
21 @terminal_statuses [:sent, :permanently_failed]
22
23 @enforce_keys [:notification_id, :user_id, :type, :channel, :template_id, :status, :created_at]
24
:-(
defstruct [
25 :notification_id,
26 :user_id,
27 :type,
28 :channel,
29 :template_id,
30 :subject,
31 :body,
32 :recipient_address,
33 :correlation_id,
34 :idempotency_key,
35 :status,
36 :failure_reason,
37 :sent_at,
38 :created_at,
39 attempts: 0,
40 max_attempts: 3,
41 payload: %{},
42 metadata: %{}
43 ]
44
45 @type channel :: :email | :sms | :push | :in_app
46 @type notification_type :: :transaction_alert | :security_alert | :promotional | :system | :otp
47 @type status :: :queued | :sending | :sent | :failed | :permanently_failed
48
49 @type t :: %__MODULE__{
50 notification_id: String.t(),
51 user_id: String.t(),
52 type: notification_type(),
53 channel: channel(),
54 template_id: String.t(),
55 subject: String.t() | nil,
56 body: String.t() | nil,
57 recipient_address: String.t() | nil,
58 correlation_id: String.t() | nil,
59 idempotency_key: String.t() | nil,
60 status: status(),
61 failure_reason: String.t() | nil,
62 sent_at: DateTime.t() | nil,
63 created_at: DateTime.t(),
64 attempts: non_neg_integer(),
65 max_attempts: pos_integer(),
66 payload: map(),
67 metadata: map()
68 }
69
70 @doc "Creates a new queued notification."
71 @spec new(
72 user_id :: String.t(),
73 type :: notification_type(),
74 channel :: channel(),
75 template_id :: String.t(),
76 opts :: keyword()
77 ) :: t()
78 2 def new(user_id, type, channel, template_id, opts \\ []) do
79 39 %__MODULE__{
80 notification_id: TypedId.generate("ntf"),
81 user_id: user_id,
82 type: type,
83 channel: channel,
84 template_id: template_id,
85 subject: Keyword.get(opts, :subject),
86 body: Keyword.get(opts, :body),
87 recipient_address: Keyword.get(opts, :recipient_address),
88 correlation_id: Keyword.get(opts, :correlation_id),
89 idempotency_key: Keyword.get(opts, :idempotency_key),
90 status: :queued,
91 attempts: 0,
92 max_attempts: Keyword.get(opts, :max_attempts, 3),
93 payload: Keyword.get(opts, :payload, %{}),
94 metadata: Keyword.get(opts, :metadata, %{}),
95 created_at: DateTime.utc_now()
96 }
97 end
98
99 @doc "Marks notification as sending (attempt started)."
100 @spec mark_sending(t()) :: {:ok, t()} | {:error, :invalid_transition}
101 22 def mark_sending(%__MODULE__{status: s} = n) when s in [:queued] do
102 22 {:ok, %{n | status: :sending, attempts: n.attempts + 1}}
103 end
104
:-(
def mark_sending(%__MODULE__{}), do: {:error, :invalid_transition}
105
106 @doc "Marks notification as sent."
107 @spec mark_sent(t()) :: {:ok, t()} | {:error, :invalid_transition}
108 12 def mark_sent(%__MODULE__{status: :sending} = n) do
109 {:ok, %{n | status: :sent, sent_at: DateTime.utc_now()}}
110 end
111
:-(
def mark_sent(%__MODULE__{}), do: {:error, :invalid_transition}
112
113 @doc "Records a delivery failure. If attempts < max_attempts, status is :failed (retryable). Otherwise :permanently_failed."
114 @spec mark_failed(t(), reason :: String.t()) :: {:ok, t()} | {:error, :invalid_transition}
115 def mark_failed(%__MODULE__{status: :sending} = n, reason) do
116 10 status = if n.attempts >= n.max_attempts, do: :permanently_failed, else: :failed
117 {:ok, %{n | status: status, failure_reason: reason}}
118 end
119
:-(
def mark_failed(%__MODULE__{}), do: {:error, :invalid_transition}
120
121 @doc "Re-queues a failed notification for retry."
122 @spec requeue(t()) :: {:ok, t()} | {:error, :invalid_transition}
123 2 def requeue(%__MODULE__{status: :failed} = n) do
124 {:ok, %{n | status: :queued, failure_reason: nil}}
125 end
126 1 def requeue(%__MODULE__{}), do: {:error, :invalid_transition}
127
128 @doc "Returns true if the notification is in a terminal state."
129 @spec terminal?(t()) :: boolean()
130 4 def terminal?(%__MODULE__{status: s}), do: s in @terminal_statuses
131
132
:-(
def valid_channels, do: @channels
133
:-(
def valid_types, do: @types
134 end
Line Hits Source