add newsletter and email campaigns
Subscribers with double opt-in confirmation, campaign composer with draft/scheduled/sent lifecycle, admin dashboard with overview stats, CSV export, and shop signup form wired into page builder blocks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8f989d892d
commit
ad2e6d1e6d
20
PROGRESS.md
20
PROGRESS.md
@ -128,16 +128,16 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
|
||||
| 91 | Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards | 89 | 1.5h | planned |
|
||||
| 92 | Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav | 89 | 2h | planned |
|
||||
| | **Admin & page editor UX polish** ([plan](docs/plans/admin-ux-polish.md)) | | | |
|
||||
| 103 | Unsaved changes warning — `beforeunload` + LiveView nav guard on page editor | — | 30m | planned |
|
||||
| 104 | Block descriptions in picker — add subtitle text to each block type | — | 45m | planned |
|
||||
| 105 | Sidebar nav grouping — section headers or nest Email/Redirects under Settings | — | 45m | planned |
|
||||
| 106 | Nav editor input labels — visible labels above each input pair | — | 30m | planned |
|
||||
| 107 | Custom page settings inline — collapsible panel in editor instead of separate page | — | 1h | planned |
|
||||
| 108 | Preview with real data — load actual products/categories instead of PreviewData | — | 45m | planned |
|
||||
| 109 | Block content preview in list — one-line summary below block name | — | 45m | planned |
|
||||
| 110 | "Providers" label clarity — rename to "Print providers" in sidebar | — | 5m | planned |
|
||||
| 111 | Newsletter block backend — wire up email collection or mark as decorative | — | 1-3h | planned |
|
||||
| 112 | Block preview thumbnails in picker — small illustrations per block type | — | 2h | planned |
|
||||
| ~~103~~ | ~~Unsaved changes warning — `beforeunload` + LiveView nav guard on page editor~~ | — | 30m | done |
|
||||
| ~~104~~ | ~~Block descriptions in picker — add subtitle text to each block type~~ | — | 45m | done |
|
||||
| ~~105~~ | ~~Sidebar nav grouping — section headers (Shop/Content/Settings)~~ | — | 45m | done |
|
||||
| ~~106~~ | ~~Nav editor input labels — visible labels above each input pair~~ | — | 30m | done |
|
||||
| ~~107~~ | ~~Custom page settings inline — collapsible panel in editor~~ | — | 1h | done |
|
||||
| ~~108~~ | ~~Preview with real data — load actual products/categories~~ | — | 45m | done |
|
||||
| ~~109~~ | ~~Block content preview in list — one-line summary below block name~~ | — | 45m | done |
|
||||
| ~~110~~ | ~~"Providers" label clarity — renamed to "Print providers"~~ | — | 5m | done |
|
||||
| ~~111~~ | ~~Newsletter block backend — marked decorative with configurable settings~~ | — | 30m | done |
|
||||
| ~~112~~ | ~~Block preview thumbnails in picker — SVG wireframes per block type~~ | — | 2h | done |
|
||||
| | **Other features** | | | |
|
||||
| ~~72~~ | ~~Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt)~~ | — | 1.5h | done |
|
||||
| | **Abandoned cart recovery** ([plan](docs/plans/abandoned-cart.md)) | | | |
|
||||
|
||||
@ -132,6 +132,7 @@
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
.mt-10 { margin-top: 2.5rem; }
|
||||
.-mt-1 { margin-top: -0.25rem; }
|
||||
.-mb-px { margin-bottom: -1px; }
|
||||
.mb-0\.5 { margin-bottom: 0.125rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
@ -158,6 +159,7 @@
|
||||
======================================== */
|
||||
|
||||
.w-0 { width: 0; }
|
||||
.w-1\.5 { width: 0.375rem; }
|
||||
.w-3 { width: 0.75rem; }
|
||||
.w-4 { width: 1rem; }
|
||||
.w-5 { width: 1.25rem; }
|
||||
@ -202,6 +204,7 @@
|
||||
.min-w-48 { min-width: 12rem; }
|
||||
|
||||
.max-h-full { max-height: 100%; }
|
||||
.max-h-64 { max-height: 16rem; }
|
||||
.max-w-80 { max-width: 20rem; }
|
||||
.max-w-sm { max-width: 24rem; }
|
||||
.max-w-md { max-width: 28rem; }
|
||||
@ -241,6 +244,7 @@
|
||||
|
||||
.text-wrap { text-wrap: wrap; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.whitespace-pre-wrap { white-space: pre-wrap; }
|
||||
.break-all { word-break: break-all; }
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.capitalize { text-transform: capitalize; }
|
||||
@ -308,17 +312,20 @@
|
||||
.border-green-200 { border-color: #bbf7d0; }
|
||||
|
||||
.bg-red-50 { background-color: #fef2f2; }
|
||||
.bg-red-500 { background-color: #ef4444; }
|
||||
.text-red-600 { color: #dc2626; }
|
||||
.text-red-700 { color: #b91c1c; }
|
||||
|
||||
.bg-amber-50 { background-color: #fffbeb; }
|
||||
.bg-amber-100 { background-color: #fef3c7; }
|
||||
.bg-amber-500 { background-color: #f59e0b; }
|
||||
.text-amber-600 { color: #d97706; }
|
||||
.text-amber-700 { color: #b45309; }
|
||||
.text-amber-800 { color: #92400e; }
|
||||
.text-amber-900 { color: #78350f; }
|
||||
|
||||
.bg-blue-50 { background-color: #eff6ff; }
|
||||
.bg-blue-500 { background-color: #3b82f6; }
|
||||
.text-blue-700 { color: #1d4ed8; }
|
||||
|
||||
.bg-purple-50 { background-color: #faf5ff; }
|
||||
@ -342,6 +349,7 @@
|
||||
.border-t-0 { border-top-width: 0; }
|
||||
.border-dashed { border-style: dashed; }
|
||||
|
||||
.border-transparent { border-color: transparent; }
|
||||
.border-base-200 { border-color: var(--t-surface-sunken); }
|
||||
.border-base-300 { border-color: var(--t-border-default); }
|
||||
.border-base-content\/20 { border-color: color-mix(in oklch, var(--t-text-primary) 20%, transparent); }
|
||||
@ -375,6 +383,10 @@
|
||||
Ring (outline via box-shadow)
|
||||
======================================== */
|
||||
|
||||
.ring-0 {
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.ring-1 {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 1px var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
@ -391,6 +403,10 @@
|
||||
Shadow
|
||||
======================================== */
|
||||
|
||||
.shadow {
|
||||
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.shadow-xs {
|
||||
--tw-shadow: 0 1px rgb(0 0 0 / 0.05);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
@ -427,6 +443,7 @@
|
||||
======================================== */
|
||||
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.pointer-events-none { pointer-events: none; }
|
||||
|
||||
/* ========================================
|
||||
Gradient
|
||||
|
||||
@ -96,10 +96,12 @@ config :berrypod, Oban,
|
||||
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
|
||||
{"0 3 * * *", Berrypod.Analytics.RetentionWorker},
|
||||
{"0 4 * * *", Berrypod.Orders.AbandonedCartPruneWorker},
|
||||
{"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker}
|
||||
{"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker},
|
||||
{"0 2 * * *", Berrypod.Newsletter.CleanupWorker},
|
||||
{"*/5 * * * *", Berrypod.Newsletter.ScheduledCampaignWorker}
|
||||
]}
|
||||
],
|
||||
queues: [images: 2, sync: 1, checkout: 1]
|
||||
queues: [images: 2, sync: 1, checkout: 1, newsletter: 1]
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Admin & page editor UX polish
|
||||
|
||||
Status: Planned
|
||||
Status: Complete
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
314
lib/berrypod/newsletter.ex
Normal file
314
lib/berrypod/newsletter.ex
Normal file
@ -0,0 +1,314 @@
|
||||
defmodule Berrypod.Newsletter do
|
||||
@moduledoc """
|
||||
Newsletter subscriber management and campaign sending.
|
||||
|
||||
Privacy-first design: double opt-in, plain text only, no tracking pixels,
|
||||
hashed confirmation tokens, minimal data collection (email only).
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Newsletter.{Campaign, Subscriber}
|
||||
alias Berrypod.{Repo, Settings}
|
||||
|
||||
# ── Subscriber flow ──────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Subscribes an email address. Creates a pending subscriber and enqueues
|
||||
a confirmation email.
|
||||
|
||||
Returns `{:ok, subscriber}` for new signups, `{:already_confirmed, subscriber}`
|
||||
if the email is already confirmed, or `{:error, changeset}` on validation failure.
|
||||
|
||||
Options:
|
||||
- `consent_text` — the exact wording shown at signup
|
||||
- `source` — "website" (default) or "admin_import"
|
||||
- `ip_hash` — hashed IP for consent proof
|
||||
"""
|
||||
def subscribe(email, opts \\ []) do
|
||||
email = email |> String.trim() |> String.downcase()
|
||||
|
||||
case Repo.get_by(Subscriber, email: email) do
|
||||
%Subscriber{status: "confirmed"} = sub ->
|
||||
{:already_confirmed, sub}
|
||||
|
||||
%Subscriber{status: "unsubscribed"} = sub ->
|
||||
# Re-subscribe: reset to pending with new token
|
||||
resubscribe(sub, opts)
|
||||
|
||||
%Subscriber{status: "pending"} = sub ->
|
||||
# Already pending — resend confirmation
|
||||
enqueue_confirmation(sub)
|
||||
{:ok, sub}
|
||||
|
||||
nil ->
|
||||
create_subscriber(email, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_subscriber(email, opts) do
|
||||
{raw_token, hashed_token} = generate_token()
|
||||
|
||||
attrs = %{
|
||||
email: email,
|
||||
status: "pending",
|
||||
confirmation_token: hashed_token,
|
||||
consent_text: Keyword.get(opts, :consent_text),
|
||||
source: Keyword.get(opts, :source, "website"),
|
||||
ip_hash: Keyword.get(opts, :ip_hash)
|
||||
}
|
||||
|
||||
case %Subscriber{} |> Subscriber.changeset(attrs) |> Repo.insert() do
|
||||
{:ok, sub} ->
|
||||
enqueue_confirmation(%{sub | confirmation_token: raw_token})
|
||||
{:ok, sub}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp resubscribe(sub, opts) do
|
||||
{raw_token, hashed_token} = generate_token()
|
||||
|
||||
attrs = %{
|
||||
status: "pending",
|
||||
confirmation_token: hashed_token,
|
||||
unsubscribed_at: nil,
|
||||
consent_text: Keyword.get(opts, :consent_text) || sub.consent_text,
|
||||
ip_hash: Keyword.get(opts, :ip_hash) || sub.ip_hash
|
||||
}
|
||||
|
||||
case sub |> Subscriber.changeset(attrs) |> Repo.update() do
|
||||
{:ok, updated} ->
|
||||
enqueue_confirmation(%{updated | confirmation_token: raw_token})
|
||||
{:ok, updated}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a subscriber via their raw confirmation token.
|
||||
"""
|
||||
def confirm(raw_token) do
|
||||
hashed = hash_token(raw_token)
|
||||
|
||||
case Repo.get_by(Subscriber, confirmation_token: hashed) do
|
||||
%Subscriber{status: "pending"} = sub ->
|
||||
sub
|
||||
|> Subscriber.changeset(%{
|
||||
status: "confirmed",
|
||||
confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
confirmation_token: nil
|
||||
})
|
||||
|> Repo.update()
|
||||
|
||||
%Subscriber{status: "confirmed"} = sub ->
|
||||
{:ok, sub}
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_token}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unsubscribes an email. Updates the subscriber record and adds to the
|
||||
global email suppression list.
|
||||
"""
|
||||
def unsubscribe(email) do
|
||||
email = email |> String.trim() |> String.downcase()
|
||||
|
||||
case Repo.get_by(Subscriber, email: email) do
|
||||
%Subscriber{status: "unsubscribed"} = sub ->
|
||||
{:ok, sub}
|
||||
|
||||
%Subscriber{} = sub ->
|
||||
sub
|
||||
|> Subscriber.changeset(%{
|
||||
status: "unsubscribed",
|
||||
unsubscribed_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
confirmation_token: nil
|
||||
})
|
||||
|> Repo.update()
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Subscriber queries ───────────────────────────────────────────
|
||||
|
||||
@doc "Lists subscribers, optionally filtered by status or email search."
|
||||
def list_subscribers(opts \\ []) do
|
||||
query =
|
||||
from(s in Subscriber, order_by: [desc: s.inserted_at])
|
||||
|
||||
query =
|
||||
case Keyword.get(opts, :status) do
|
||||
nil -> query
|
||||
"all" -> query
|
||||
status -> from(s in query, where: s.status == ^status)
|
||||
end
|
||||
|
||||
query =
|
||||
case Keyword.get(opts, :search) do
|
||||
nil -> query
|
||||
"" -> query
|
||||
term -> from(s in query, where: like(s.email, ^"%#{term}%"))
|
||||
end
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc "Returns subscriber counts grouped by status."
|
||||
def count_subscribers_by_status do
|
||||
from(s in Subscriber,
|
||||
group_by: s.status,
|
||||
select: {s.status, count(s.id)}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc "Returns the count of confirmed subscribers."
|
||||
def confirmed_subscriber_count do
|
||||
from(s in Subscriber, where: s.status == "confirmed", select: count())
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_subscriber!(id), do: Repo.get!(Subscriber, id)
|
||||
|
||||
@doc "Hard-deletes a subscriber (GDPR right to erasure)."
|
||||
def delete_subscriber(%Subscriber{} = sub) do
|
||||
Repo.delete(sub)
|
||||
end
|
||||
|
||||
@doc "Returns all data held about a subscriber as a plain map (GDPR right to access)."
|
||||
def export_subscriber_data(%Subscriber{} = sub) do
|
||||
%{
|
||||
email: sub.email,
|
||||
status: sub.status,
|
||||
subscribed_at: sub.inserted_at,
|
||||
confirmed_at: sub.confirmed_at,
|
||||
unsubscribed_at: sub.unsubscribed_at,
|
||||
source: sub.source,
|
||||
consent_text: sub.consent_text
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Exports all subscribers as a CSV string."
|
||||
def export_all_subscribers_csv do
|
||||
subscribers = list_subscribers()
|
||||
|
||||
header = "email,status,confirmed_at,subscribed_at\n"
|
||||
|
||||
rows =
|
||||
Enum.map_join(subscribers, "\n", fn s ->
|
||||
[
|
||||
s.email,
|
||||
s.status,
|
||||
format_csv_datetime(s.confirmed_at),
|
||||
format_csv_datetime(s.inserted_at)
|
||||
]
|
||||
|> Enum.join(",")
|
||||
end)
|
||||
|
||||
header <> rows
|
||||
end
|
||||
|
||||
defp format_csv_datetime(nil), do: ""
|
||||
defp format_csv_datetime(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# ── Campaign CRUD ────────────────────────────────────────────────
|
||||
|
||||
def list_campaigns do
|
||||
from(c in Campaign, order_by: [desc: c.inserted_at])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_campaign!(id), do: Repo.get!(Campaign, id)
|
||||
|
||||
def create_campaign(attrs) do
|
||||
%Campaign{}
|
||||
|> Campaign.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_campaign(%Campaign{} = campaign, attrs) do
|
||||
campaign
|
||||
|> Campaign.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_campaign(%Campaign{status: "draft"} = campaign) do
|
||||
Repo.delete(campaign)
|
||||
end
|
||||
|
||||
def delete_campaign(_campaign), do: {:error, :not_draft}
|
||||
|
||||
@doc "Marks a campaign as sending and enqueues the send worker."
|
||||
def send_campaign_now(%Campaign{status: status} = campaign)
|
||||
when status in ["draft", "scheduled"] do
|
||||
case update_campaign(campaign, %{status: "sending"}) do
|
||||
{:ok, updated} ->
|
||||
Berrypod.Newsletter.CampaignSendWorker.enqueue(updated.id)
|
||||
{:ok, updated}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def send_campaign_now(_), do: {:error, :invalid_status}
|
||||
|
||||
@doc "Schedules a campaign for future sending."
|
||||
def schedule_campaign(%Campaign{status: "draft"} = campaign, %DateTime{} = at) do
|
||||
update_campaign(campaign, %{status: "scheduled", scheduled_at: at})
|
||||
end
|
||||
|
||||
def schedule_campaign(_, _), do: {:error, :not_draft}
|
||||
|
||||
@doc "Cancels a scheduled campaign."
|
||||
def cancel_campaign(%Campaign{status: "scheduled"} = campaign) do
|
||||
update_campaign(campaign, %{status: "cancelled", scheduled_at: nil})
|
||||
end
|
||||
|
||||
def cancel_campaign(_), do: {:error, :not_scheduled}
|
||||
|
||||
@doc "Returns a preview of the campaign body with a sample unsubscribe URL."
|
||||
def preview_campaign(%Campaign{body: body}) do
|
||||
sample_url = BerrypodWeb.Endpoint.url() <> "/unsubscribe/sample-token"
|
||||
String.replace(body, "{{unsubscribe_url}}", sample_url)
|
||||
end
|
||||
|
||||
# ── Settings ─────────────────────────────────────────────────────
|
||||
|
||||
def newsletter_enabled? do
|
||||
Settings.get_setting("newsletter_enabled", false) == true
|
||||
end
|
||||
|
||||
def set_newsletter_enabled(enabled?) when is_boolean(enabled?) do
|
||||
Settings.put_setting("newsletter_enabled", enabled?, "boolean")
|
||||
end
|
||||
|
||||
# ── Token helpers ────────────────────────────────────────────────
|
||||
|
||||
defp generate_token do
|
||||
raw = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
||||
{raw, hash_token(raw)}
|
||||
end
|
||||
|
||||
defp hash_token(raw) do
|
||||
:crypto.hash(:sha256, raw) |> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
# ── Confirmation email enqueue ───────────────────────────────────
|
||||
|
||||
defp enqueue_confirmation(subscriber) do
|
||||
%{subscriber_id: subscriber.id, raw_token: subscriber.confirmation_token}
|
||||
|> Berrypod.Newsletter.ConfirmationEmailWorker.new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
end
|
||||
27
lib/berrypod/newsletter/campaign.ex
Normal file
27
lib/berrypod/newsletter/campaign.ex
Normal file
@ -0,0 +1,27 @@
|
||||
defmodule Berrypod.Newsletter.Campaign do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "newsletter_campaigns" do
|
||||
field :subject, :string
|
||||
field :body, :string
|
||||
field :status, :string, default: "draft"
|
||||
field :scheduled_at, :utc_datetime
|
||||
field :sent_at, :utc_datetime
|
||||
field :sent_count, :integer, default: 0
|
||||
field :failed_count, :integer, default: 0
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(campaign, attrs) do
|
||||
campaign
|
||||
|> cast(attrs, [:subject, :body, :status, :scheduled_at, :sent_at, :sent_count, :failed_count])
|
||||
|> validate_required([:subject, :body])
|
||||
|> validate_length(:subject, max: 200)
|
||||
|> validate_inclusion(:status, ~w(draft scheduled sending sent cancelled))
|
||||
end
|
||||
end
|
||||
75
lib/berrypod/newsletter/campaign_send_worker.ex
Normal file
75
lib/berrypod/newsletter/campaign_send_worker.ex
Normal file
@ -0,0 +1,75 @@
|
||||
defmodule Berrypod.Newsletter.CampaignSendWorker do
|
||||
@moduledoc """
|
||||
Sends a campaign to all confirmed, non-suppressed subscribers.
|
||||
|
||||
Processes in batches of 50 with a brief pause between batches to
|
||||
respect mail provider rate limits.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 1
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Notifier
|
||||
|
||||
require Logger
|
||||
|
||||
@batch_size 50
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"campaign_id" => id}}) do
|
||||
campaign = Newsletter.get_campaign!(id)
|
||||
|
||||
unless campaign.status in ["sending", "scheduled"] do
|
||||
Logger.info("Campaign #{id} status is #{campaign.status}, skipping")
|
||||
return_ok()
|
||||
end
|
||||
|
||||
# Ensure status is "sending"
|
||||
{:ok, campaign} =
|
||||
if campaign.status == "scheduled" do
|
||||
Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||
else
|
||||
{:ok, campaign}
|
||||
end
|
||||
|
||||
subscribers = Newsletter.list_subscribers(status: "confirmed")
|
||||
{sent, failed} = send_in_batches(campaign, subscribers)
|
||||
|
||||
Newsletter.update_campaign(campaign, %{
|
||||
status: "sent",
|
||||
sent_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
sent_count: sent,
|
||||
failed_count: failed
|
||||
})
|
||||
|
||||
Logger.info("Campaign #{id} sent: #{sent} delivered, #{failed} failed")
|
||||
:ok
|
||||
end
|
||||
|
||||
def enqueue(campaign_id) do
|
||||
%{campaign_id: campaign_id}
|
||||
|> new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp send_in_batches(campaign, subscribers) do
|
||||
subscribers
|
||||
|> Enum.chunk_every(@batch_size)
|
||||
|> Enum.reduce({0, 0}, fn batch, {sent, failed} ->
|
||||
{batch_sent, batch_failed} = send_batch(campaign, batch)
|
||||
Process.sleep(100)
|
||||
{sent + batch_sent, failed + batch_failed}
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_batch(campaign, subscribers) do
|
||||
Enum.reduce(subscribers, {0, 0}, fn sub, {sent, failed} ->
|
||||
case Notifier.deliver_campaign(campaign, sub) do
|
||||
{:ok, _} -> {sent + 1, failed}
|
||||
{:error, _} -> {sent, failed + 1}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp return_ok, do: :ok
|
||||
end
|
||||
39
lib/berrypod/newsletter/cleanup_worker.ex
Normal file
39
lib/berrypod/newsletter/cleanup_worker.ex
Normal file
@ -0,0 +1,39 @@
|
||||
defmodule Berrypod.Newsletter.CleanupWorker do
|
||||
@moduledoc """
|
||||
Prunes unconfirmed newsletter subscribers older than 48 hours.
|
||||
|
||||
Runs daily via cron. Pending signups that never confirmed are deleted
|
||||
to keep the subscriber list clean and respect data minimisation.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 1
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Newsletter.Subscriber
|
||||
alias Berrypod.Repo
|
||||
|
||||
require Logger
|
||||
|
||||
@hours_to_expire 48
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
cutoff =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.add(-@hours_to_expire * 3600)
|
||||
|> DateTime.truncate(:second)
|
||||
|
||||
{count, _} =
|
||||
from(s in Subscriber,
|
||||
where: s.status == "pending" and s.inserted_at < ^cutoff
|
||||
)
|
||||
|> Repo.delete_all()
|
||||
|
||||
if count > 0 do
|
||||
Logger.info("Newsletter cleanup: pruned #{count} unconfirmed subscribers")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
36
lib/berrypod/newsletter/confirmation_email_worker.ex
Normal file
36
lib/berrypod/newsletter/confirmation_email_worker.ex
Normal file
@ -0,0 +1,36 @@
|
||||
defmodule Berrypod.Newsletter.ConfirmationEmailWorker do
|
||||
@moduledoc """
|
||||
Sends the double opt-in confirmation email to a new subscriber.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 3
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Notifier
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"subscriber_id" => id, "raw_token" => raw_token}}) do
|
||||
case Berrypod.Repo.get(Newsletter.Subscriber, id) do
|
||||
%{status: "pending"} = sub ->
|
||||
case Notifier.deliver_confirmation(sub, raw_token) do
|
||||
{:ok, _} ->
|
||||
Logger.info("Newsletter confirmation sent to #{sub.email}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Newsletter confirmation failed for #{sub.email}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
|
||||
%{status: _other} ->
|
||||
Logger.info("Newsletter confirmation skipped: subscriber #{id} no longer pending")
|
||||
:ok
|
||||
|
||||
nil ->
|
||||
Logger.warning("Newsletter confirmation: subscriber #{id} not found")
|
||||
{:cancel, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
98
lib/berrypod/newsletter/notifier.ex
Normal file
98
lib/berrypod/newsletter/notifier.ex
Normal file
@ -0,0 +1,98 @@
|
||||
defmodule Berrypod.Newsletter.Notifier do
|
||||
@moduledoc """
|
||||
Plain text email templates for newsletter confirmation and campaigns.
|
||||
"""
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Berrypod.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc "Sends the double opt-in confirmation email."
|
||||
def deliver_confirmation(subscriber, raw_token) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
confirm_url = BerrypodWeb.Endpoint.url() <> "/newsletter/confirm/" <> raw_token
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Thanks for signing up to the #{shop_name} newsletter!
|
||||
|
||||
Please confirm your email to start receiving updates:
|
||||
|
||||
#{confirm_url}
|
||||
|
||||
This link expires in 48 hours. If you didn't subscribe,
|
||||
just ignore this email and you won't hear from us.
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(subscriber.email, "Confirm your subscription", body)
|
||||
end
|
||||
|
||||
@doc "Sends a campaign email to a single subscriber."
|
||||
def deliver_campaign(campaign, subscriber) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
unsubscribe_url = build_unsubscribe_url(subscriber.email)
|
||||
|
||||
body_with_url =
|
||||
String.replace(campaign.body, "{{unsubscribe_url}}", unsubscribe_url)
|
||||
|
||||
full_body = """
|
||||
#{body_with_url}
|
||||
|
||||
---
|
||||
You're receiving this because you subscribed to the #{shop_name} newsletter.
|
||||
Unsubscribe: #{unsubscribe_url}
|
||||
"""
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(subscriber.email)
|
||||
|> from({shop_name, from_address()})
|
||||
|> subject(campaign.subject)
|
||||
|> text_body(full_body)
|
||||
|> header("List-Unsubscribe", "<#{unsubscribe_url}>")
|
||||
|> header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _metadata} = result ->
|
||||
result
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Newsletter send failed for #{subscriber.email}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({shop_name, from_address()})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _metadata} = result ->
|
||||
result
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Failed to send newsletter email to #{recipient}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp from_address do
|
||||
Berrypod.Settings.get_setting("email_from_address", "noreply@example.com")
|
||||
end
|
||||
|
||||
defp build_unsubscribe_url(email) do
|
||||
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, "email-unsub", email)
|
||||
BerrypodWeb.Endpoint.url() <> "/unsubscribe/" <> token
|
||||
end
|
||||
end
|
||||
36
lib/berrypod/newsletter/scheduled_campaign_worker.ex
Normal file
36
lib/berrypod/newsletter/scheduled_campaign_worker.ex
Normal file
@ -0,0 +1,36 @@
|
||||
defmodule Berrypod.Newsletter.ScheduledCampaignWorker do
|
||||
@moduledoc """
|
||||
Picks up scheduled campaigns that are due and triggers sending.
|
||||
|
||||
Runs every 5 minutes via cron. Simpler than using Oban's schedule_in
|
||||
because it lets admins cancel scheduled campaigns by changing status.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 1
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Campaign
|
||||
alias Berrypod.Repo
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
campaigns =
|
||||
from(c in Campaign,
|
||||
where: c.status == "scheduled" and c.scheduled_at <= ^now
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
for campaign <- campaigns do
|
||||
Logger.info("Triggering scheduled campaign #{campaign.id}: #{campaign.subject}")
|
||||
Newsletter.send_campaign_now(campaign)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
39
lib/berrypod/newsletter/subscriber.ex
Normal file
39
lib/berrypod/newsletter/subscriber.ex
Normal file
@ -0,0 +1,39 @@
|
||||
defmodule Berrypod.Newsletter.Subscriber do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "newsletter_subscribers" do
|
||||
field :email, :string
|
||||
field :status, :string, default: "pending"
|
||||
field :confirmation_token, :string
|
||||
field :confirmed_at, :utc_datetime
|
||||
field :unsubscribed_at, :utc_datetime
|
||||
field :consent_text, :string
|
||||
field :source, :string, default: "website"
|
||||
field :ip_hash, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(subscriber, attrs) do
|
||||
subscriber
|
||||
|> cast(attrs, [
|
||||
:email,
|
||||
:status,
|
||||
:confirmation_token,
|
||||
:confirmed_at,
|
||||
:unsubscribed_at,
|
||||
:consent_text,
|
||||
:source,
|
||||
:ip_hash
|
||||
])
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
||||
|> validate_inclusion(:status, ~w(pending confirmed unsubscribed))
|
||||
|> unique_constraint(:email)
|
||||
|> update_change(:email, &String.downcase(String.trim(&1)))
|
||||
end
|
||||
end
|
||||
@ -110,11 +110,9 @@ defmodule Berrypod.Pages.BlockTypes do
|
||||
},
|
||||
"newsletter_card" => %{
|
||||
name: "Newsletter signup",
|
||||
description: "Email signup form — currently decorative, does not collect emails",
|
||||
description: "Email signup form with double opt-in — enable in admin settings",
|
||||
icon: "hero-envelope",
|
||||
allowed_on: :all,
|
||||
hint:
|
||||
"This block is decorative — form submissions aren't collected yet. Use it as a placeholder or remove it.",
|
||||
settings_schema: [
|
||||
%SettingsField{key: "title", label: "Title", type: :text, default: "Newsletter"},
|
||||
%SettingsField{
|
||||
|
||||
@ -126,6 +126,14 @@
|
||||
<.icon name="hero-photo" class="size-5" /> Media
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/admin/newsletter"}
|
||||
class={admin_nav_active?(@current_path, "/admin/newsletter")}
|
||||
>
|
||||
<.icon name="hero-megaphone" class="size-5" /> Newsletter
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href={~p"/admin/theme"}
|
||||
|
||||
@ -286,16 +286,18 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
|
||||
## Attributes
|
||||
|
||||
* `title` - Optional. Card heading. Defaults to "Stay in touch".
|
||||
* `title` - Optional. Card heading. Defaults to "Newsletter".
|
||||
* `description` - Optional. Card description.
|
||||
* `button_text` - Optional. Button text. Defaults to "Subscribe".
|
||||
* `variant` - Optional. Either `:card` (default, with border/background) or `:inline` (no card styling, for embedding in footer).
|
||||
* `newsletter_state` - Optional. `:idle | :submitted | :error | :disabled`. Defaults to `:idle`.
|
||||
* `newsletter_enabled` - Optional. Whether signups are active. Defaults to `true`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.newsletter_card />
|
||||
<.newsletter_card title="Studio news" description="Get updates on new products." />
|
||||
<.newsletter_card variant={:inline} />
|
||||
<.newsletter_card variant={:inline} newsletter_state={@newsletter_state} />
|
||||
"""
|
||||
attr :title, :string, default: "Newsletter"
|
||||
|
||||
@ -304,6 +306,30 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
|
||||
attr :button_text, :string, default: "Subscribe"
|
||||
attr :variant, :atom, default: :card
|
||||
attr :newsletter_state, :atom, default: :idle
|
||||
attr :newsletter_enabled, :boolean, default: true
|
||||
|
||||
def newsletter_card(%{newsletter_state: :submitted, variant: :inline} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3 class="newsletter-heading">{@title}</h3>
|
||||
<p class="card-text card-text--spaced">
|
||||
Check your inbox to confirm your subscription.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def newsletter_card(%{newsletter_state: :submitted} = assigns) do
|
||||
~H"""
|
||||
<.shop_card class="card-section">
|
||||
<h3 class="card-heading">{@title}</h3>
|
||||
<p class="card-text card-text--spaced">
|
||||
Check your inbox to confirm your subscription.
|
||||
</p>
|
||||
</.shop_card>
|
||||
"""
|
||||
end
|
||||
|
||||
def newsletter_card(%{variant: :inline} = assigns) do
|
||||
~H"""
|
||||
@ -314,10 +340,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
<p class="card-text card-text--spaced">
|
||||
{@description}
|
||||
</p>
|
||||
<form class="card-inline-form" onsubmit="return false">
|
||||
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
|
||||
<.shop_button type="submit">{@button_text}</.shop_button>
|
||||
</form>
|
||||
<%= if @newsletter_enabled do %>
|
||||
<form
|
||||
action="/newsletter/subscribe"
|
||||
method="post"
|
||||
phx-submit="newsletter_subscribe"
|
||||
class="card-inline-form"
|
||||
>
|
||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||
<.shop_input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
class="email-input"
|
||||
required
|
||||
/>
|
||||
<.shop_button type="submit">{@button_text}</.shop_button>
|
||||
</form>
|
||||
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@ -329,10 +372,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
||||
<p class="card-text card-text--spaced">
|
||||
{@description}
|
||||
</p>
|
||||
<form class="card-inline-form" onsubmit="return false">
|
||||
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
|
||||
<.shop_button type="submit">{@button_text}</.shop_button>
|
||||
</form>
|
||||
<%= if @newsletter_enabled do %>
|
||||
<form
|
||||
action="/newsletter/subscribe"
|
||||
method="post"
|
||||
phx-submit="newsletter_subscribe"
|
||||
class="card-inline-form"
|
||||
>
|
||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||
<.shop_input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
class="email-input"
|
||||
required
|
||||
/>
|
||||
<.shop_button type="submit">{@button_text}</.shop_button>
|
||||
</form>
|
||||
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
<% end %>
|
||||
</.shop_card>
|
||||
"""
|
||||
end
|
||||
|
||||
@ -52,7 +52,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
|
||||
search_query search_results search_open categories shipping_estimate
|
||||
country_code available_countries editing editor_current_path editor_sidebar_open
|
||||
header_nav_items footer_nav_items)a
|
||||
header_nav_items footer_nav_items newsletter_enabled newsletter_state)a
|
||||
|
||||
@doc """
|
||||
Extracts the assigns relevant to `shop_layout` from a full assigns map.
|
||||
@ -98,6 +98,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
attr :available_countries, :list, default: []
|
||||
attr :header_nav_items, :list, default: []
|
||||
attr :footer_nav_items, :list, default: []
|
||||
attr :newsletter_enabled, :boolean, default: false
|
||||
attr :newsletter_state, :atom, default: :idle
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
@ -136,6 +138,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
mode={@mode}
|
||||
categories={assigns[:categories] || []}
|
||||
footer_nav_items={@footer_nav_items}
|
||||
newsletter_enabled={@newsletter_enabled}
|
||||
newsletter_state={@newsletter_state}
|
||||
/>
|
||||
|
||||
<.cart_drawer
|
||||
@ -522,6 +526,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
attr :mode, :atom, default: :live
|
||||
attr :categories, :list, default: []
|
||||
attr :footer_nav_items, :list, default: []
|
||||
attr :newsletter_enabled, :boolean, default: false
|
||||
attr :newsletter_state, :atom, default: :idle
|
||||
|
||||
def shop_footer(assigns) do
|
||||
assigns = assign(assigns, :current_year, Date.utc_today().year)
|
||||
@ -530,7 +536,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
||||
<footer class="shop-footer">
|
||||
<div class="shop-footer-inner">
|
||||
<div class="footer-grid">
|
||||
<.newsletter_card variant={:inline} />
|
||||
<.newsletter_card
|
||||
variant={:inline}
|
||||
newsletter_enabled={@newsletter_enabled}
|
||||
newsletter_state={@newsletter_state}
|
||||
/>
|
||||
|
||||
<div class="footer-links">
|
||||
<div>
|
||||
|
||||
108
lib/berrypod_web/controllers/newsletter_controller.ex
Normal file
108
lib/berrypod_web/controllers/newsletter_controller.ex
Normal file
@ -0,0 +1,108 @@
|
||||
defmodule BerrypodWeb.NewsletterController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
@doc "No-JS fallback for newsletter signup form."
|
||||
def subscribe(conn, %{"email" => email}) do
|
||||
ip_hash = hash_ip(conn)
|
||||
|
||||
case Newsletter.subscribe(email,
|
||||
consent_text: "Newsletter signup on website",
|
||||
ip_hash: ip_hash
|
||||
) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "Check your inbox to confirm your subscription.")
|
||||
|> redirect(to: redirect_back(conn))
|
||||
|
||||
{:already_confirmed, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "You're already subscribed!")
|
||||
|> redirect(to: redirect_back(conn))
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_flash(:error, "Please enter a valid email address.")
|
||||
|> redirect(to: redirect_back(conn))
|
||||
end
|
||||
end
|
||||
|
||||
def subscribe(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:error, "Please enter your email address.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@doc "Handles confirmation link from the double opt-in email."
|
||||
def confirm(conn, %{"token" => token}) do
|
||||
case Newsletter.confirm(token) do
|
||||
{:ok, _sub} ->
|
||||
conn
|
||||
|> put_status(200)
|
||||
|> html(confirmation_html(:success))
|
||||
|
||||
{:error, :invalid_token} ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> html(confirmation_html(:invalid))
|
||||
end
|
||||
end
|
||||
|
||||
defp confirmation_html(:success) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Subscription confirmed</title>
|
||||
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You're subscribed!</h1>
|
||||
<p style="color:#555">Thanks for confirming. You'll receive the #{shop_name} newsletter from now on.</p>
|
||||
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
|
||||
defp confirmation_html(:invalid) do
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Invalid link</title>
|
||||
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">Link invalid or expired</h1>
|
||||
<p style="color:#555">This confirmation link has expired or is invalid. Please try subscribing again.</p>
|
||||
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
|
||||
defp redirect_back(conn) do
|
||||
case get_req_header(conn, "referer") do
|
||||
[referer | _] ->
|
||||
uri = URI.parse(referer)
|
||||
uri.path || "/"
|
||||
|
||||
_ ->
|
||||
"/"
|
||||
end
|
||||
end
|
||||
|
||||
defp hash_ip(conn) do
|
||||
daily_salt = Date.utc_today() |> Date.to_iso8601()
|
||||
ip_string = conn.remote_ip |> :inet.ntoa() |> to_string()
|
||||
:crypto.hash(:sha256, ip_string <> daily_salt) |> Base.encode16(case: :lower)
|
||||
end
|
||||
end
|
||||
16
lib/berrypod_web/controllers/newsletter_export_controller.ex
Normal file
16
lib/berrypod_web/controllers/newsletter_export_controller.ex
Normal file
@ -0,0 +1,16 @@
|
||||
defmodule BerrypodWeb.NewsletterExportController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
def export(conn, _params) do
|
||||
csv = Newsletter.export_all_subscribers_csv()
|
||||
today = Date.to_iso8601(Date.utc_today())
|
||||
filename = "newsletter-subscribers-#{today}.csv"
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/csv")
|
||||
|> put_resp_header("content-disposition", ~s(attachment; filename="#{filename}"))
|
||||
|> send_resp(200, csv)
|
||||
end
|
||||
end
|
||||
@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.UnsubscribeController do
|
||||
use BerrypodWeb, :controller
|
||||
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.{Newsletter, Orders}
|
||||
|
||||
# Unsubscribe links should be long-lived — use 2 years
|
||||
@max_age 2 * 365 * 24 * 3600
|
||||
@ -10,6 +10,7 @@ defmodule BerrypodWeb.UnsubscribeController do
|
||||
case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do
|
||||
{:ok, email} ->
|
||||
Orders.add_suppression(email, "unsubscribed")
|
||||
Newsletter.unsubscribe(email)
|
||||
|
||||
conn
|
||||
|> put_status(200)
|
||||
@ -24,7 +25,7 @@ defmodule BerrypodWeb.UnsubscribeController do
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1>
|
||||
<p style="color:#555">We've removed #{email} from our marketing list. You won't receive any more cart recovery emails from us.</p>
|
||||
<p style="color:#555">We've removed #{email} from our marketing emails. You won't hear from us again.</p>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
501
lib/berrypod_web/live/admin/newsletter.ex
Normal file
501
lib/berrypod_web/live/admin/newsletter.ex
Normal file
@ -0,0 +1,501 @@
|
||||
defmodule BerrypodWeb.Admin.Newsletter do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
counts = Newsletter.count_subscribers_by_status()
|
||||
subscribers = Newsletter.list_subscribers()
|
||||
campaigns = Newsletter.list_campaigns()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Newsletter")
|
||||
|> assign(:tab, "overview")
|
||||
|> assign(:newsletter_enabled, Newsletter.newsletter_enabled?())
|
||||
|> assign(:status_counts, counts)
|
||||
|> assign(:subscriber_count, length(subscribers))
|
||||
|> assign(:campaign_count, length(campaigns))
|
||||
|> assign(:status_filter, "all")
|
||||
|> assign(:search, "")
|
||||
|> stream(:subscribers, subscribers)
|
||||
|> stream(:campaigns, campaigns)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"tab" => tab}, _uri, socket)
|
||||
when tab in ~w(overview subscribers campaigns) do
|
||||
socket = assign(socket, :tab, tab)
|
||||
|
||||
socket =
|
||||
case tab do
|
||||
"subscribers" ->
|
||||
subscribers =
|
||||
Newsletter.list_subscribers(
|
||||
status: socket.assigns.status_filter,
|
||||
search: socket.assigns.search
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(:subscriber_count, length(subscribers))
|
||||
|> stream(:subscribers, subscribers, reset: true)
|
||||
|
||||
"campaigns" ->
|
||||
campaigns = Newsletter.list_campaigns()
|
||||
|
||||
socket
|
||||
|> assign(:campaign_count, length(campaigns))
|
||||
|> stream(:campaigns, campaigns, reset: true)
|
||||
|
||||
_ ->
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_enabled", _params, socket) do
|
||||
new_value = !socket.assigns.newsletter_enabled
|
||||
Newsletter.set_newsletter_enabled(new_value)
|
||||
|
||||
msg = if new_value, do: "Newsletter signups enabled", else: "Newsletter signups disabled"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:newsletter_enabled, new_value)
|
||||
|> put_flash(:info, msg)}
|
||||
end
|
||||
|
||||
def handle_event("filter_subscribers", %{"status" => status}, socket) do
|
||||
subscribers = Newsletter.list_subscribers(status: status, search: socket.assigns.search)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:status_filter, status)
|
||||
|> assign(:subscriber_count, length(subscribers))
|
||||
|> stream(:subscribers, subscribers, reset: true)}
|
||||
end
|
||||
|
||||
def handle_event("search_subscribers", %{"search" => term}, socket) do
|
||||
subscribers = Newsletter.list_subscribers(status: socket.assigns.status_filter, search: term)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:search, term)
|
||||
|> assign(:subscriber_count, length(subscribers))
|
||||
|> stream(:subscribers, subscribers, reset: true)}
|
||||
end
|
||||
|
||||
def handle_event("delete_subscriber", %{"id" => id}, socket) do
|
||||
sub = Newsletter.get_subscriber!(id)
|
||||
{:ok, _} = Newsletter.delete_subscriber(sub)
|
||||
|
||||
counts = Newsletter.count_subscribers_by_status()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:status_counts, counts)
|
||||
|> assign(:subscriber_count, socket.assigns.subscriber_count - 1)
|
||||
|> stream_delete(:subscribers, sub)
|
||||
|> put_flash(:info, "Subscriber deleted")}
|
||||
end
|
||||
|
||||
def handle_event("delete_campaign", %{"id" => id}, socket) do
|
||||
campaign = Newsletter.get_campaign!(id)
|
||||
|
||||
case Newsletter.delete_campaign(campaign) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:campaign_count, socket.assigns.campaign_count - 1)
|
||||
|> stream_delete(:campaigns, campaign)
|
||||
|> put_flash(:info, "Campaign deleted")}
|
||||
|
||||
{:error, :not_draft} ->
|
||||
{:noreply, put_flash(socket, :error, "Only draft campaigns can be deleted")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Newsletter
|
||||
<:subtitle>Manage subscribers and email campaigns</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="flex gap-2 mt-6 mb-6 border-b border-base-200">
|
||||
<.tab_link label="Overview" tab="overview" active={@tab} />
|
||||
<.tab_link
|
||||
label="Subscribers"
|
||||
tab="subscribers"
|
||||
active={@tab}
|
||||
count={total_subs(@status_counts)}
|
||||
/>
|
||||
<.tab_link label="Campaigns" tab="campaigns" active={@tab} count={@campaign_count} />
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "overview"}>
|
||||
<.overview_tab
|
||||
newsletter_enabled={@newsletter_enabled}
|
||||
status_counts={@status_counts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "subscribers"}>
|
||||
<.subscribers_tab
|
||||
streams={@streams}
|
||||
status_filter={@status_filter}
|
||||
status_counts={@status_counts}
|
||||
subscriber_count={@subscriber_count}
|
||||
search={@search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "campaigns"}>
|
||||
<.campaigns_tab streams={@streams} campaign_count={@campaign_count} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Tab navigation ──────────────────────────────────────────────
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :tab, :string, required: true
|
||||
attr :active, :string, required: true
|
||||
attr :count, :integer, default: nil
|
||||
|
||||
defp tab_link(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
patch={~p"/admin/newsletter?tab=#{@tab}"}
|
||||
class={[
|
||||
"px-3 py-2 text-sm font-medium border-b-2 -mb-px",
|
||||
if(@tab == @active,
|
||||
do: "border-base-content text-base-content",
|
||||
else:
|
||||
"border-transparent text-base-content/60 hover:text-base-content hover:border-base-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
<span :if={@count} class="ml-1 text-xs text-base-content/40">{@count}</span>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Overview tab ────────────────────────────────────────────────
|
||||
|
||||
attr :newsletter_enabled, :boolean, required: true
|
||||
attr :status_counts, :map, required: true
|
||||
|
||||
defp overview_tab(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-4 p-4 rounded-lg border border-base-200">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium">Newsletter signups</h3>
|
||||
<p class="text-sm text-base-content/60 mt-0.5">
|
||||
When enabled, the newsletter signup block on your shop pages will collect email addresses with double opt-in confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
phx-click="toggle_enabled"
|
||||
class={[
|
||||
"relative inline-flex h-6 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
if(@newsletter_enabled, do: "bg-green-600", else: "bg-base-300")
|
||||
]}
|
||||
role="switch"
|
||||
aria-checked={to_string(@newsletter_enabled)}
|
||||
aria-label="Toggle newsletter signups"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform"
|
||||
style={"transform: translateX(#{if @newsletter_enabled, do: "1.5rem", else: "0"})"}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["confirmed"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Confirmed</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["pending"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Pending</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["unsubscribed"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Unsubscribed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="text-sm font-medium underline">
|
||||
View subscribers
|
||||
</.link>
|
||||
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="text-sm font-medium underline">
|
||||
View campaigns
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Subscribers tab ─────────────────────────────────────────────
|
||||
|
||||
attr :streams, :any, required: true
|
||||
attr :status_filter, :string, required: true
|
||||
attr :status_counts, :map, required: true
|
||||
attr :subscriber_count, :integer, required: true
|
||||
attr :search, :string, required: true
|
||||
|
||||
defp subscribers_tab(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<.filter_pill
|
||||
status="all"
|
||||
label="All"
|
||||
count={total_subs(@status_counts)}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_pill
|
||||
status="confirmed"
|
||||
label="Confirmed"
|
||||
count={@status_counts["confirmed"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_pill
|
||||
status="pending"
|
||||
label="Pending"
|
||||
count={@status_counts["pending"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
<.filter_pill
|
||||
status="unsubscribed"
|
||||
label="Unsubscribed"
|
||||
count={@status_counts["unsubscribed"]}
|
||||
active={@status_filter}
|
||||
/>
|
||||
</div>
|
||||
<.link href={~p"/admin/newsletter/export"} class="admin-btn admin-btn-sm admin-btn-ghost">
|
||||
<.icon name="hero-arrow-down-tray" class="size-4" /> Export CSV
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<form phx-change="search_subscribers" class="mb-4">
|
||||
<.input
|
||||
name="search"
|
||||
value={@search}
|
||||
type="search"
|
||||
placeholder="Search by email..."
|
||||
phx-debounce="300"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<.table
|
||||
:if={@subscriber_count > 0}
|
||||
id="subscribers"
|
||||
rows={@streams.subscribers}
|
||||
row_item={fn {_id, sub} -> sub end}
|
||||
>
|
||||
<:col :let={sub} label="Email">{sub.email}</:col>
|
||||
<:col :let={sub} label="Status"><.subscriber_status status={sub.status} /></:col>
|
||||
<:col :let={sub} label="Subscribed">{format_date(sub.inserted_at)}</:col>
|
||||
<:col :let={sub} label="Source">{sub.source}</:col>
|
||||
<:action :let={sub}>
|
||||
<button
|
||||
phx-click="delete_subscriber"
|
||||
phx-value-id={sub.id}
|
||||
data-confirm="Permanently delete this subscriber? This cannot be undone."
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<div :if={@subscriber_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No subscribers yet</p>
|
||||
<p class="text-sm mt-1">Subscribers will appear here when people sign up via your shop.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Campaigns tab ───────────────────────────────────────────────
|
||||
|
||||
attr :streams, :any, required: true
|
||||
attr :campaign_count, :integer, required: true
|
||||
|
||||
defp campaigns_tab(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="flex justify-end mb-4">
|
||||
<.link navigate={~p"/admin/newsletter/campaigns/new"} class="admin-btn admin-btn-primary">
|
||||
<.icon name="hero-plus" class="size-4" /> New campaign
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
:if={@campaign_count > 0}
|
||||
id="campaigns"
|
||||
rows={@streams.campaigns}
|
||||
row_item={fn {_id, c} -> c end}
|
||||
row_click={fn {_id, c} -> JS.navigate(~p"/admin/newsletter/campaigns/#{c.id}") end}
|
||||
>
|
||||
<:col :let={c} label="Subject">{c.subject}</:col>
|
||||
<:col :let={c} label="Status"><.campaign_status status={c.status} /></:col>
|
||||
<:col :let={c} label="Sent">{c.sent_count}</:col>
|
||||
<:col :let={c} label="Created">{format_date(c.inserted_at)}</:col>
|
||||
<:action :let={c}>
|
||||
<button
|
||||
:if={c.status == "draft"}
|
||||
phx-click="delete_campaign"
|
||||
phx-value-id={c.id}
|
||||
data-confirm="Delete this draft campaign?"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<div :if={@campaign_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No campaigns yet</p>
|
||||
<p class="text-sm mt-1">Create your first campaign to reach your subscribers.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Components ──────────────────────────────────────────────────
|
||||
|
||||
attr :status, :string, required: true
|
||||
attr :label, :string, required: true
|
||||
attr :count, :integer, default: nil
|
||||
attr :active, :string, required: true
|
||||
|
||||
defp filter_pill(assigns) do
|
||||
count = assigns[:count] || 0
|
||||
active = assigns.status == assigns.active
|
||||
|
||||
assigns = assign(assigns, count: count, active: active)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
phx-click="filter_subscribers"
|
||||
phx-value-status={@status}
|
||||
class={[
|
||||
"admin-btn admin-btn-sm",
|
||||
@active && "admin-btn-primary",
|
||||
!@active && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :status, :string, required: true
|
||||
|
||||
defp subscriber_status(%{status: "confirmed"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-green-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Confirmed
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(%{status: "pending"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-amber-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Pending
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(%{status: "unsubscribed"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-base-content/50">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Unsubscribed
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(assigns) do
|
||||
~H"""
|
||||
<span class="text-sm text-base-content/50">{@status}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :status, :string, required: true
|
||||
|
||||
defp campaign_status(%{status: "draft"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-base-content/60">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Draft
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "scheduled"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-blue-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span> Scheduled
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "sending"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-amber-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Sending
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "sent"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-green-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Sent
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "cancelled"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-red-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span> Cancelled
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(assigns) do
|
||||
~H"""
|
||||
<span class="text-sm text-base-content/50">{@status}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
defp total_subs(counts) do
|
||||
Enum.reduce(counts, 0, fn {_status, count}, acc -> acc + count end)
|
||||
end
|
||||
|
||||
defp format_date(nil), do: "—"
|
||||
|
||||
defp format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%-d %b %Y")
|
||||
end
|
||||
end
|
||||
236
lib/berrypod_web/live/admin/newsletter/campaign_form.ex
Normal file
236
lib/berrypod_web/live/admin/newsletter/campaign_form.ex
Normal file
@ -0,0 +1,236 @@
|
||||
defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "New campaign")
|
||||
|> assign(:campaign, nil)
|
||||
|> assign(:subscriber_count, Newsletter.confirmed_subscriber_count())
|
||||
|> assign(:form, to_form(%{"subject" => "", "body" => ""}, as: :campaign))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _uri, socket) do
|
||||
campaign = Newsletter.get_campaign!(id)
|
||||
|
||||
title =
|
||||
if campaign.status == "draft",
|
||||
do: "Edit campaign",
|
||||
else: "Campaign: #{campaign.subject}"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, title)
|
||||
|> assign(:campaign, campaign)
|
||||
|> assign(
|
||||
:form,
|
||||
to_form(
|
||||
%{
|
||||
"subject" => campaign.subject,
|
||||
"body" => campaign.body
|
||||
},
|
||||
as: :campaign
|
||||
)
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"campaign" => params}, socket) do
|
||||
{:noreply, assign(socket, :form, to_form(params, as: :campaign))}
|
||||
end
|
||||
|
||||
def handle_event("save_draft", %{"campaign" => params}, socket) do
|
||||
case save_campaign(socket.assigns.campaign, params) do
|
||||
{:ok, campaign} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:campaign, campaign)
|
||||
|> put_flash(:info, "Campaign saved")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Please fill in subject and body")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("send_now", _params, socket) do
|
||||
params = current_form_params(socket)
|
||||
|
||||
with {:ok, campaign} <- save_campaign(socket.assigns.campaign, params),
|
||||
{:ok, campaign} <- Newsletter.send_campaign_now(campaign) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:campaign, campaign)
|
||||
|> put_flash(:info, "Campaign is being sent!")
|
||||
|> push_navigate(to: ~p"/admin/newsletter?tab=campaigns")}
|
||||
else
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to send campaign")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("schedule", _params, socket) do
|
||||
params = current_form_params(socket)
|
||||
scheduled_at = parse_schedule_time(params["scheduled_at"])
|
||||
|
||||
with {:ok, campaign} <- save_campaign(socket.assigns.campaign, params),
|
||||
{:ok, _campaign} <- Newsletter.schedule_campaign(campaign, scheduled_at) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Campaign scheduled")
|
||||
|> push_navigate(to: ~p"/admin/newsletter?tab=campaigns")}
|
||||
else
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to schedule campaign")}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_campaign(nil, params) do
|
||||
Newsletter.create_campaign(%{
|
||||
subject: params["subject"],
|
||||
body: params["body"]
|
||||
})
|
||||
end
|
||||
|
||||
defp save_campaign(%{status: "draft"} = campaign, params) do
|
||||
Newsletter.update_campaign(campaign, %{
|
||||
subject: params["subject"],
|
||||
body: params["body"]
|
||||
})
|
||||
end
|
||||
|
||||
defp save_campaign(campaign, _params), do: {:ok, campaign}
|
||||
|
||||
defp parse_schedule_time(nil), do: DateTime.utc_now() |> DateTime.add(3600)
|
||||
defp parse_schedule_time(""), do: DateTime.utc_now() |> DateTime.add(3600)
|
||||
|
||||
defp parse_schedule_time(str) do
|
||||
case DateTime.from_iso8601(str <> ":00Z") do
|
||||
{:ok, dt, _} -> dt
|
||||
_ -> DateTime.utc_now() |> DateTime.add(3600)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
<%= cond do %>
|
||||
<% readonly?(@campaign) -> %>
|
||||
This campaign was sent on {format_date(@campaign.sent_at)} to {@campaign.sent_count} subscribers
|
||||
<% @subscriber_count > 0 -> %>
|
||||
{@subscriber_count} confirmed subscribers will receive this email
|
||||
<% true -> %>
|
||||
No confirmed subscribers yet
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 max-w-2xl">
|
||||
<.form for={@form} phx-change="validate" phx-submit="save_draft">
|
||||
<div class="space-y-4">
|
||||
<.input
|
||||
field={@form[:subject]}
|
||||
type="text"
|
||||
label="Subject"
|
||||
required
|
||||
disabled={readonly?(@campaign)}
|
||||
placeholder="Your campaign subject"
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={@form[:body]}
|
||||
type="textarea"
|
||||
label="Body (plain text)"
|
||||
required
|
||||
disabled={readonly?(@campaign)}
|
||||
rows="12"
|
||||
placeholder="Hello!\n\nYour newsletter content here.\n\nUnsubscribe: {{unsubscribe_url}}"
|
||||
/>
|
||||
|
||||
<p :if={!readonly?(@campaign)} class="text-sm text-base-content/60">
|
||||
Use <code>{"{{unsubscribe_url}}"}</code>
|
||||
to insert the unsubscribe link. This is required for GDPR compliance.
|
||||
</p>
|
||||
|
||||
<p
|
||||
:if={missing_unsubscribe_url?(@form[:body].value) && !readonly?(@campaign)}
|
||||
class="flex items-center gap-2 text-sm text-amber-700"
|
||||
>
|
||||
<.icon name="hero-exclamation-triangle" class="size-4 shrink-0" /> Body is missing
|
||||
<code>{"{{unsubscribe_url}}"}</code>
|
||||
— this is required for GDPR compliance.
|
||||
</p>
|
||||
|
||||
<%= if @form[:body].value && @form[:body].value != "" do %>
|
||||
<details class="mt-4">
|
||||
<summary class="text-sm font-medium cursor-pointer">Preview</summary>
|
||||
<pre class="mt-2 p-4 bg-base-200 rounded-lg text-sm whitespace-pre-wrap overflow-auto max-h-64">{preview_body(@form[:body].value)}</pre>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
:if={!readonly?(@campaign)}
|
||||
class="flex items-center gap-3 pt-4 border-t border-base-200"
|
||||
>
|
||||
<.button type="submit">
|
||||
Save draft
|
||||
</.button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
phx-click="send_now"
|
||||
data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"}
|
||||
class="admin-btn admin-btn-primary"
|
||||
style="background-color: var(--color-green-600)"
|
||||
disabled={@subscriber_count == 0}
|
||||
>
|
||||
<.icon name="hero-paper-airplane" class="size-4" /> Send now
|
||||
</button>
|
||||
|
||||
<.link
|
||||
navigate={~p"/admin/newsletter?tab=campaigns"}
|
||||
class="admin-btn admin-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div :if={readonly?(@campaign)} class="pt-4 border-t border-base-200">
|
||||
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-btn admin-btn-ghost">
|
||||
<.icon name="hero-arrow-left" class="size-4" /> Back to campaigns
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp current_form_params(socket) do
|
||||
form = socket.assigns.form
|
||||
%{"subject" => form[:subject].value || "", "body" => form[:body].value || ""}
|
||||
end
|
||||
|
||||
defp readonly?(%{status: status}) when status not in ["draft"], do: true
|
||||
defp readonly?(_), do: false
|
||||
|
||||
defp missing_unsubscribe_url?(nil), do: false
|
||||
defp missing_unsubscribe_url?(""), do: false
|
||||
defp missing_unsubscribe_url?(body), do: not String.contains?(body, "{{unsubscribe_url}}")
|
||||
|
||||
defp format_date(nil), do: "—"
|
||||
defp format_date(datetime), do: Calendar.strftime(datetime, "%-d %b %Y")
|
||||
|
||||
defp preview_body(body) do
|
||||
sample_url = BerrypodWeb.Endpoint.url() <> "/unsubscribe/sample-token"
|
||||
String.replace(body, "{{unsubscribe_url}}", sample_url)
|
||||
end
|
||||
end
|
||||
60
lib/berrypod_web/newsletter_hook.ex
Normal file
60
lib/berrypod_web/newsletter_hook.ex
Normal file
@ -0,0 +1,60 @@
|
||||
defmodule BerrypodWeb.NewsletterHook do
|
||||
@moduledoc """
|
||||
LiveView on_mount hook for newsletter signup across all shop pages.
|
||||
|
||||
Uses `attach_hook/4` to intercept `newsletter_subscribe` events globally
|
||||
without modifying individual shop LiveViews.
|
||||
"""
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4]
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
def on_mount(:mount_newsletter, _params, _session, socket) do
|
||||
enabled = Newsletter.newsletter_enabled?()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:newsletter_enabled, enabled)
|
||||
|> assign(:newsletter_state, :idle)
|
||||
|> assign(:newsletter_ip_hash, hash_ip(socket))
|
||||
|> attach_hook(:newsletter, :handle_event, &handle_newsletter_event/3)
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
defp handle_newsletter_event("newsletter_subscribe", %{"email" => email}, socket) do
|
||||
if socket.assigns.newsletter_enabled do
|
||||
case Newsletter.subscribe(email,
|
||||
consent_text: "Newsletter signup on website",
|
||||
ip_hash: socket.assigns.newsletter_ip_hash
|
||||
) do
|
||||
{:ok, _} ->
|
||||
{:halt, assign(socket, :newsletter_state, :submitted)}
|
||||
|
||||
{:already_confirmed, _} ->
|
||||
{:halt, assign(socket, :newsletter_state, :submitted)}
|
||||
|
||||
{:error, _} ->
|
||||
{:halt, assign(socket, :newsletter_state, :error)}
|
||||
end
|
||||
else
|
||||
{:halt, assign(socket, :newsletter_state, :disabled)}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_newsletter_event(_event, _params, socket), do: {:cont, socket}
|
||||
|
||||
# connect_info is only available during mount, so we hash and store it early
|
||||
defp hash_ip(socket) do
|
||||
case Phoenix.LiveView.get_connect_info(socket, :peer_data) do
|
||||
%{address: ip} ->
|
||||
daily_salt = Date.utc_today() |> Date.to_iso8601()
|
||||
:crypto.hash(:sha256, :inet.ntoa(ip) ++ [daily_salt]) |> Base.encode16(case: :lower)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -310,8 +310,18 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
|> assign(:title, settings["title"] || "Newsletter")
|
||||
|> assign(:description, settings["description"] || "")
|
||||
|> assign(:button_text, settings["button_text"] || "Subscribe")
|
||||
|> assign(:newsletter_state, assigns[:newsletter_state] || :idle)
|
||||
|> assign(:newsletter_enabled, assigns[:newsletter_enabled] || false)
|
||||
|
||||
~H"<.newsletter_card title={@title} description={@description} button_text={@button_text} />"
|
||||
~H"""
|
||||
<.newsletter_card
|
||||
title={@title}
|
||||
description={@description}
|
||||
button_text={@button_text}
|
||||
newsletter_state={@newsletter_state}
|
||||
newsletter_enabled={@newsletter_enabled}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
|
||||
|
||||
@ -136,6 +136,7 @@ defmodule BerrypodWeb.Router do
|
||||
pipe_through [:browser, :require_authenticated_user, :admin]
|
||||
|
||||
get "/analytics/export", AnalyticsExportController, :export
|
||||
get "/newsletter/export", NewsletterExportController, :export
|
||||
|
||||
live_session :admin,
|
||||
layout: {BerrypodWeb.Layouts, :admin},
|
||||
@ -160,6 +161,9 @@ defmodule BerrypodWeb.Router do
|
||||
live "/pages/:slug", Admin.Pages.Editor, :edit
|
||||
live "/navigation", Admin.Navigation, :index
|
||||
live "/media", Admin.Media, :index
|
||||
live "/newsletter", Admin.Newsletter, :index
|
||||
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
|
||||
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
|
||||
live "/redirects", Admin.Redirects, :index
|
||||
end
|
||||
|
||||
@ -203,6 +207,7 @@ defmodule BerrypodWeb.Router do
|
||||
|
||||
get "/orders/verify/:token", OrderLookupController, :verify
|
||||
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
||||
get "/newsletter/confirm/:token", NewsletterController, :confirm
|
||||
end
|
||||
|
||||
# Dev-only routes (mailbox preview, error previews)
|
||||
@ -247,7 +252,8 @@ defmodule BerrypodWeb.Router do
|
||||
{BerrypodWeb.CartHook, :mount_cart},
|
||||
{BerrypodWeb.SearchHook, :mount_search},
|
||||
{BerrypodWeb.AnalyticsHook, :track},
|
||||
{BerrypodWeb.PageEditorHook, :mount_page_editor}
|
||||
{BerrypodWeb.PageEditorHook, :mount_page_editor},
|
||||
{BerrypodWeb.NewsletterHook, :mount_newsletter}
|
||||
] do
|
||||
live "/", Shop.Home, :index
|
||||
live "/about", Shop.Content, :about
|
||||
@ -274,6 +280,9 @@ defmodule BerrypodWeb.Router do
|
||||
post "/contact/send", ContactController, :create
|
||||
post "/contact/lookup", OrderLookupController, :lookup
|
||||
|
||||
# Newsletter signup (no-JS fallback)
|
||||
post "/newsletter/subscribe", NewsletterController, :subscribe
|
||||
|
||||
# Cart form actions (no-JS fallbacks for LiveView cart events)
|
||||
post "/cart/add", CartController, :add
|
||||
post "/cart/remove", CartController, :remove
|
||||
|
||||
39
priv/repo/migrations/20260228204908_create_newsletter.exs
Normal file
39
priv/repo/migrations/20260228204908_create_newsletter.exs
Normal file
@ -0,0 +1,39 @@
|
||||
defmodule Berrypod.Repo.Migrations.CreateNewsletter do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:newsletter_subscribers, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :email, :string, null: false
|
||||
add :status, :string, null: false, default: "pending"
|
||||
add :confirmation_token, :string
|
||||
add :confirmed_at, :utc_datetime
|
||||
add :unsubscribed_at, :utc_datetime
|
||||
add :consent_text, :string
|
||||
add :source, :string, default: "website"
|
||||
add :ip_hash, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:newsletter_subscribers, [:email])
|
||||
create index(:newsletter_subscribers, [:status])
|
||||
create index(:newsletter_subscribers, [:confirmation_token])
|
||||
|
||||
create table(:newsletter_campaigns, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :subject, :string, null: false
|
||||
add :body, :text, null: false
|
||||
add :status, :string, null: false, default: "draft"
|
||||
add :scheduled_at, :utc_datetime
|
||||
add :sent_at, :utc_datetime
|
||||
add :sent_count, :integer, default: 0
|
||||
add :failed_count, :integer, default: 0
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:newsletter_campaigns, [:status])
|
||||
create index(:newsletter_campaigns, [:scheduled_at])
|
||||
end
|
||||
end
|
||||
50
test/berrypod/newsletter/campaign_send_worker_test.exs
Normal file
50
test/berrypod/newsletter/campaign_send_worker_test.exs
Normal file
@ -0,0 +1,50 @@
|
||||
defmodule Berrypod.Newsletter.CampaignSendWorkerTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
use Oban.Testing, repo: Berrypod.Repo
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.CampaignSendWorker
|
||||
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
describe "perform/1" do
|
||||
test "sends campaign to confirmed subscribers" do
|
||||
confirmed_subscriber_fixture(email: "a@example.com")
|
||||
confirmed_subscriber_fixture(email: "b@example.com")
|
||||
|
||||
campaign = campaign_fixture(subject: "Hello!", body: "Test body {{unsubscribe_url}}")
|
||||
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||
|
||||
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||
|
||||
campaign = Newsletter.get_campaign!(campaign.id)
|
||||
assert campaign.status == "sent"
|
||||
assert campaign.sent_count == 2
|
||||
assert campaign.failed_count == 0
|
||||
assert campaign.sent_at != nil
|
||||
end
|
||||
|
||||
test "only sends to confirmed subscribers" do
|
||||
confirmed_subscriber_fixture(email: "active@example.com")
|
||||
|
||||
# Create an unsubscribed subscriber — should be skipped
|
||||
sub = confirmed_subscriber_fixture(email: "unsub@example.com")
|
||||
Newsletter.unsubscribe(sub.email)
|
||||
|
||||
campaign = campaign_fixture()
|
||||
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||
|
||||
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||
|
||||
campaign = Newsletter.get_campaign!(campaign.id)
|
||||
assert campaign.sent_count == 1
|
||||
end
|
||||
|
||||
test "skips campaigns with wrong status" do
|
||||
campaign = campaign_fixture()
|
||||
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sent"})
|
||||
|
||||
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||
end
|
||||
end
|
||||
end
|
||||
48
test/berrypod/newsletter/cleanup_worker_test.exs
Normal file
48
test/berrypod/newsletter/cleanup_worker_test.exs
Normal file
@ -0,0 +1,48 @@
|
||||
defmodule Berrypod.Newsletter.CleanupWorkerTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
use Oban.Testing, repo: Berrypod.Repo
|
||||
|
||||
alias Berrypod.Newsletter.{CleanupWorker, Subscriber}
|
||||
alias Berrypod.Repo
|
||||
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
describe "perform/1" do
|
||||
test "prunes pending subscribers older than 48 hours" do
|
||||
# Insert an old pending subscriber directly
|
||||
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
|
||||
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{email: "old@example.com", status: "pending"})
|
||||
|> Ecto.Changeset.force_change(:inserted_at, old_time)
|
||||
|> Repo.insert!()
|
||||
|
||||
# Insert a fresh pending subscriber
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{email: "fresh@example.com", status: "pending"})
|
||||
|> Repo.insert!()
|
||||
|
||||
assert :ok = perform_job(CleanupWorker, %{})
|
||||
|
||||
# Old pending subscriber should be deleted
|
||||
assert Repo.get_by(Subscriber, email: "old@example.com") == nil
|
||||
# Fresh one should remain
|
||||
assert Repo.get_by(Subscriber, email: "fresh@example.com") != nil
|
||||
end
|
||||
|
||||
test "does not prune confirmed subscribers" do
|
||||
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
|
||||
|
||||
confirmed = confirmed_subscriber_fixture(email: "confirmed@example.com")
|
||||
|
||||
# Backdate the subscriber
|
||||
confirmed
|
||||
|> Ecto.Changeset.change(inserted_at: old_time)
|
||||
|> Repo.update!()
|
||||
|
||||
assert :ok = perform_job(CleanupWorker, %{})
|
||||
|
||||
assert Repo.get_by(Subscriber, email: "confirmed@example.com") != nil
|
||||
end
|
||||
end
|
||||
end
|
||||
45
test/berrypod/newsletter/confirmation_email_worker_test.exs
Normal file
45
test/berrypod/newsletter/confirmation_email_worker_test.exs
Normal file
@ -0,0 +1,45 @@
|
||||
defmodule Berrypod.Newsletter.ConfirmationEmailWorkerTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
use Oban.Testing, repo: Berrypod.Repo
|
||||
|
||||
alias Berrypod.Newsletter.ConfirmationEmailWorker
|
||||
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
describe "perform/1" do
|
||||
test "sends confirmation email for pending subscriber" do
|
||||
# Create subscriber in manual mode so the confirmation job doesn't auto-run
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
{:ok, sub} = Berrypod.Newsletter.subscribe("test@example.com")
|
||||
|
||||
[job] = all_enqueued(worker: ConfirmationEmailWorker)
|
||||
raw_token = job.args["raw_token"]
|
||||
|
||||
# Now perform the job (uses Swoosh.Adapters.Local in test)
|
||||
assert :ok =
|
||||
perform_job(ConfirmationEmailWorker, %{
|
||||
subscriber_id: sub.id,
|
||||
raw_token: raw_token
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
test "skips if subscriber is no longer pending" do
|
||||
sub = confirmed_subscriber_fixture()
|
||||
|
||||
assert :ok =
|
||||
perform_job(ConfirmationEmailWorker, %{
|
||||
subscriber_id: sub.id,
|
||||
raw_token: "irrelevant"
|
||||
})
|
||||
end
|
||||
|
||||
test "cancels if subscriber not found" do
|
||||
assert {:cancel, :not_found} =
|
||||
perform_job(ConfirmationEmailWorker, %{
|
||||
subscriber_id: Ecto.UUID.generate(),
|
||||
raw_token: "irrelevant"
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
316
test/berrypod/newsletter_test.exs
Normal file
316
test/berrypod/newsletter_test.exs
Normal file
@ -0,0 +1,316 @@
|
||||
defmodule Berrypod.NewsletterTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
use Oban.Testing, repo: Berrypod.Repo
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.{Campaign, Subscriber}
|
||||
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
# ── Subscribe flow ───────────────────────────────────────────────
|
||||
|
||||
describe "subscribe/2" do
|
||||
test "creates a pending subscriber" do
|
||||
assert {:ok, %Subscriber{status: "pending"}} =
|
||||
Newsletter.subscribe("test@example.com")
|
||||
end
|
||||
|
||||
test "normalises email to lowercase" do
|
||||
{:ok, sub} = Newsletter.subscribe("Test@EXAMPLE.com")
|
||||
assert sub.email == "test@example.com"
|
||||
end
|
||||
|
||||
test "stores consent text and source" do
|
||||
{:ok, sub} =
|
||||
Newsletter.subscribe("test@example.com",
|
||||
consent_text: "Stay updated",
|
||||
source: "website"
|
||||
)
|
||||
|
||||
assert sub.consent_text == "Stay updated"
|
||||
assert sub.source == "website"
|
||||
end
|
||||
|
||||
test "stores hashed confirmation token" do
|
||||
{:ok, sub} = Newsletter.subscribe("test@example.com")
|
||||
sub = Repo.get!(Subscriber, sub.id)
|
||||
assert sub.confirmation_token != nil
|
||||
# Token is a hex-encoded SHA256 hash (64 chars)
|
||||
assert byte_size(sub.confirmation_token) == 64
|
||||
end
|
||||
|
||||
test "enqueues confirmation email worker" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
{:ok, _sub} = Newsletter.subscribe("test@example.com")
|
||||
|
||||
assert_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
|
||||
end)
|
||||
end
|
||||
|
||||
test "returns already_confirmed for confirmed subscribers" do
|
||||
sub = confirmed_subscriber_fixture(email: "test@example.com")
|
||||
|
||||
assert {:already_confirmed, ^sub} =
|
||||
Newsletter.subscribe("test@example.com")
|
||||
end
|
||||
|
||||
test "returns ok for pending subscribers (resends confirmation)" do
|
||||
{:ok, sub} = Newsletter.subscribe("test@example.com")
|
||||
assert {:ok, ^sub} = Newsletter.subscribe("test@example.com")
|
||||
end
|
||||
|
||||
test "resubscribes unsubscribed users" do
|
||||
confirmed_subscriber_fixture(email: "test@example.com")
|
||||
{:ok, _} = Newsletter.unsubscribe("test@example.com")
|
||||
|
||||
{:ok, resub} = Newsletter.subscribe("test@example.com")
|
||||
assert resub.status == "pending"
|
||||
assert resub.unsubscribed_at == nil
|
||||
end
|
||||
|
||||
test "rejects invalid email" do
|
||||
assert {:error, %Ecto.Changeset{}} = Newsletter.subscribe("not-an-email")
|
||||
end
|
||||
end
|
||||
|
||||
# ── Confirm ──────────────────────────────────────────────────────
|
||||
|
||||
describe "confirm/1" do
|
||||
test "confirms a pending subscriber with valid token" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
{:ok, _sub} = Newsletter.subscribe("test@example.com")
|
||||
|
||||
# Grab the raw token from the enqueued Oban job
|
||||
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
|
||||
raw_token = job.args["raw_token"]
|
||||
|
||||
assert {:ok, confirmed} = Newsletter.confirm(raw_token)
|
||||
assert confirmed.status == "confirmed"
|
||||
assert confirmed.confirmed_at != nil
|
||||
assert confirmed.confirmation_token == nil
|
||||
end)
|
||||
end
|
||||
|
||||
test "returns error for invalid token" do
|
||||
assert {:error, :invalid_token} = Newsletter.confirm("bogus-token")
|
||||
end
|
||||
|
||||
test "is idempotent for already confirmed subscribers" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
{:ok, _sub} = Newsletter.subscribe("test@example.com")
|
||||
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
|
||||
raw_token = job.args["raw_token"]
|
||||
|
||||
{:ok, _} = Newsletter.confirm(raw_token)
|
||||
# Token is cleared after first confirm, so second call with same token fails
|
||||
assert {:error, :invalid_token} = Newsletter.confirm(raw_token)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# ── Unsubscribe ──────────────────────────────────────────────────
|
||||
|
||||
describe "unsubscribe/1" do
|
||||
test "marks subscriber as unsubscribed" do
|
||||
confirmed_subscriber_fixture(email: "test@example.com")
|
||||
|
||||
assert {:ok, sub} = Newsletter.unsubscribe("test@example.com")
|
||||
assert sub.status == "unsubscribed"
|
||||
assert sub.unsubscribed_at != nil
|
||||
end
|
||||
|
||||
test "does not add to global suppression list" do
|
||||
confirmed_subscriber_fixture(email: "test@example.com")
|
||||
Newsletter.unsubscribe("test@example.com")
|
||||
|
||||
assert Berrypod.Orders.check_suppression("test@example.com") == :ok
|
||||
end
|
||||
|
||||
test "is idempotent" do
|
||||
confirmed_subscriber_fixture(email: "test@example.com")
|
||||
{:ok, _} = Newsletter.unsubscribe("test@example.com")
|
||||
assert {:ok, _} = Newsletter.unsubscribe("test@example.com")
|
||||
end
|
||||
|
||||
test "returns error for unknown email" do
|
||||
assert {:error, :not_found} = Newsletter.unsubscribe("unknown@example.com")
|
||||
end
|
||||
end
|
||||
|
||||
# ── Subscriber queries ───────────────────────────────────────────
|
||||
|
||||
describe "list_subscribers/1" do
|
||||
test "returns all subscribers" do
|
||||
confirmed_subscriber_fixture()
|
||||
confirmed_subscriber_fixture()
|
||||
|
||||
result = Newsletter.list_subscribers()
|
||||
assert length(result) == 2
|
||||
end
|
||||
|
||||
test "filters by status" do
|
||||
confirmed_subscriber_fixture()
|
||||
{:ok, _} = Newsletter.subscribe("pending@example.com")
|
||||
|
||||
result = Newsletter.list_subscribers(status: "confirmed")
|
||||
assert length(result) == 1
|
||||
assert hd(result).status == "confirmed"
|
||||
end
|
||||
|
||||
test "searches by email" do
|
||||
confirmed_subscriber_fixture(email: "alice@example.com")
|
||||
confirmed_subscriber_fixture(email: "bob@example.com")
|
||||
|
||||
result = Newsletter.list_subscribers(search: "alice")
|
||||
assert length(result) == 1
|
||||
assert hd(result).email == "alice@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "count_subscribers_by_status/0" do
|
||||
test "returns counts grouped by status" do
|
||||
confirmed_subscriber_fixture()
|
||||
confirmed_subscriber_fixture()
|
||||
{:ok, _} = Newsletter.subscribe("pending@example.com")
|
||||
|
||||
counts = Newsletter.count_subscribers_by_status()
|
||||
assert counts["confirmed"] == 2
|
||||
assert counts["pending"] == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "confirmed_subscriber_count/0" do
|
||||
test "returns count of confirmed subscribers" do
|
||||
confirmed_subscriber_fixture()
|
||||
confirmed_subscriber_fixture()
|
||||
{:ok, _} = Newsletter.subscribe("pending@example.com")
|
||||
|
||||
assert Newsletter.confirmed_subscriber_count() == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_subscriber/1" do
|
||||
test "hard-deletes a subscriber" do
|
||||
sub = confirmed_subscriber_fixture()
|
||||
assert {:ok, _} = Newsletter.delete_subscriber(sub)
|
||||
assert Repo.get(Subscriber, sub.id) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "export_subscriber_data/1" do
|
||||
test "returns a map of all stored data" do
|
||||
sub = confirmed_subscriber_fixture(email: "export@example.com")
|
||||
data = Newsletter.export_subscriber_data(sub)
|
||||
|
||||
assert data.email == "export@example.com"
|
||||
assert data.status == "confirmed"
|
||||
assert data.subscribed_at != nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "export_all_subscribers_csv/0" do
|
||||
test "returns CSV with header and rows" do
|
||||
confirmed_subscriber_fixture(email: "a@example.com")
|
||||
confirmed_subscriber_fixture(email: "b@example.com")
|
||||
|
||||
csv = Newsletter.export_all_subscribers_csv()
|
||||
assert csv =~ "email,status,confirmed_at,subscribed_at"
|
||||
assert csv =~ "a@example.com"
|
||||
assert csv =~ "b@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
# ── Campaign CRUD ────────────────────────────────────────────────
|
||||
|
||||
describe "campaign CRUD" do
|
||||
test "creates a draft campaign" do
|
||||
assert {:ok, %Campaign{status: "draft"}} =
|
||||
Newsletter.create_campaign(%{
|
||||
subject: "Test",
|
||||
body: "Hello {{unsubscribe_url}}"
|
||||
})
|
||||
end
|
||||
|
||||
test "lists campaigns" do
|
||||
campaign_fixture(subject: "First")
|
||||
campaign_fixture(subject: "Second")
|
||||
|
||||
result = Newsletter.list_campaigns()
|
||||
assert length(result) == 2
|
||||
end
|
||||
|
||||
test "updates a campaign" do
|
||||
campaign = campaign_fixture()
|
||||
{:ok, updated} = Newsletter.update_campaign(campaign, %{subject: "Updated"})
|
||||
assert updated.subject == "Updated"
|
||||
end
|
||||
|
||||
test "deletes a draft campaign" do
|
||||
campaign = campaign_fixture()
|
||||
assert {:ok, _} = Newsletter.delete_campaign(campaign)
|
||||
end
|
||||
|
||||
test "refuses to delete non-draft campaigns" do
|
||||
campaign = campaign_fixture()
|
||||
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
|
||||
assert {:error, :not_draft} = Newsletter.delete_campaign(sent)
|
||||
end
|
||||
end
|
||||
|
||||
describe "schedule_campaign/2" do
|
||||
test "schedules a draft campaign" do
|
||||
campaign = campaign_fixture()
|
||||
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
|
||||
|
||||
assert {:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
|
||||
assert scheduled.status == "scheduled"
|
||||
assert scheduled.scheduled_at == at
|
||||
end
|
||||
|
||||
test "refuses to schedule non-draft campaigns" do
|
||||
campaign = campaign_fixture()
|
||||
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
|
||||
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
|
||||
|
||||
assert {:error, :not_draft} = Newsletter.schedule_campaign(sent, at)
|
||||
end
|
||||
end
|
||||
|
||||
describe "cancel_campaign/1" do
|
||||
test "cancels a scheduled campaign" do
|
||||
campaign = campaign_fixture()
|
||||
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
|
||||
{:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
|
||||
|
||||
assert {:ok, cancelled} = Newsletter.cancel_campaign(scheduled)
|
||||
assert cancelled.status == "cancelled"
|
||||
end
|
||||
|
||||
test "refuses to cancel non-scheduled campaigns" do
|
||||
campaign = campaign_fixture()
|
||||
assert {:error, :not_scheduled} = Newsletter.cancel_campaign(campaign)
|
||||
end
|
||||
end
|
||||
|
||||
describe "preview_campaign/1" do
|
||||
test "replaces unsubscribe placeholder" do
|
||||
campaign = campaign_fixture(body: "Hello! Unsub: {{unsubscribe_url}}")
|
||||
preview = Newsletter.preview_campaign(campaign)
|
||||
assert preview =~ "/unsubscribe/sample-token"
|
||||
refute preview =~ "{{unsubscribe_url}}"
|
||||
end
|
||||
end
|
||||
|
||||
# ── Settings ─────────────────────────────────────────────────────
|
||||
|
||||
describe "newsletter_enabled?/0" do
|
||||
test "defaults to false" do
|
||||
refute Newsletter.newsletter_enabled?()
|
||||
end
|
||||
|
||||
test "returns true when enabled" do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
assert Newsletter.newsletter_enabled?()
|
||||
end
|
||||
end
|
||||
end
|
||||
96
test/berrypod_web/controllers/newsletter_controller_test.exs
Normal file
96
test/berrypod_web/controllers/newsletter_controller_test.exs
Normal file
@ -0,0 +1,96 @@
|
||||
defmodule BerrypodWeb.NewsletterControllerTest do
|
||||
use BerrypodWeb.ConnCase, async: true
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Subscriber
|
||||
alias Berrypod.Repo
|
||||
|
||||
describe "POST /newsletter/subscribe" do
|
||||
test "subscribes and redirects back", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("referer", "http://localhost:4002/")
|
||||
|> post(~p"/newsletter/subscribe", %{email: "new@example.com"})
|
||||
|
||||
assert redirected_to(conn) == "/"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Check your inbox"
|
||||
end
|
||||
|
||||
test "handles already confirmed subscriber", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
# Create a confirmed subscriber directly
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{
|
||||
email: "confirmed@example.com",
|
||||
status: "confirmed",
|
||||
confirmed_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("referer", "http://localhost:4002/about")
|
||||
|> post(~p"/newsletter/subscribe", %{email: "confirmed@example.com"})
|
||||
|
||||
assert redirected_to(conn) == "/about"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "already subscribed"
|
||||
end
|
||||
|
||||
test "shows error for invalid email", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("referer", "http://localhost:4002/")
|
||||
|> post(~p"/newsletter/subscribe", %{email: "not-an-email"})
|
||||
|
||||
assert redirected_to(conn) == "/"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "valid email"
|
||||
end
|
||||
|
||||
test "redirects to / when no referer", %{conn: conn} do
|
||||
Newsletter.set_newsletter_enabled(true)
|
||||
|
||||
conn = post(conn, ~p"/newsletter/subscribe", %{email: "test@example.com"})
|
||||
|
||||
assert redirected_to(conn) == "/"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /newsletter/confirm/:token" do
|
||||
test "confirms pending subscriber", %{conn: conn} do
|
||||
# Create a pending subscriber with a known raw token
|
||||
raw_token = "test-confirmation-token-abc123"
|
||||
hashed_token = :crypto.hash(:sha256, raw_token) |> Base.encode16(case: :lower)
|
||||
|
||||
%Subscriber{}
|
||||
|> Subscriber.changeset(%{
|
||||
email: "pending@example.com",
|
||||
status: "pending",
|
||||
confirmation_token: hashed_token
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
conn = get(conn, "/newsletter/confirm/#{raw_token}")
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ "subscribed"
|
||||
|
||||
# Verify the subscriber is now confirmed
|
||||
sub = Repo.get_by!(Subscriber, email: "pending@example.com")
|
||||
assert sub.status == "confirmed"
|
||||
end
|
||||
|
||||
test "returns error for invalid token", %{conn: conn} do
|
||||
conn = get(conn, "/newsletter/confirm/invalid-token-here")
|
||||
|
||||
assert conn.status == 400
|
||||
assert conn.resp_body =~ "invalid"
|
||||
end
|
||||
end
|
||||
end
|
||||
131
test/berrypod_web/live/admin/newsletter_test.exs
Normal file
131
test/berrypod_web/live/admin/newsletter_test.exs
Normal file
@ -0,0 +1,131 @@
|
||||
defmodule BerrypodWeb.Admin.NewsletterTest do
|
||||
use BerrypodWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
import Berrypod.NewsletterFixtures
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
setup %{conn: conn} do
|
||||
user = user_fixture()
|
||||
conn = log_in_user(conn, user)
|
||||
%{conn: conn, user: user}
|
||||
end
|
||||
|
||||
describe "overview tab" do
|
||||
test "shows newsletter toggle", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
|
||||
|
||||
assert has_element?(view, "[role=switch]")
|
||||
end
|
||||
|
||||
test "toggles newsletter enabled", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
|
||||
|
||||
refute Newsletter.newsletter_enabled?()
|
||||
|
||||
view |> element("[role=switch]") |> render_click()
|
||||
|
||||
assert Newsletter.newsletter_enabled?()
|
||||
end
|
||||
|
||||
test "shows subscriber counts", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "a@example.com")
|
||||
confirmed_subscriber_fixture(email: "b@example.com")
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/newsletter")
|
||||
|
||||
assert html =~ "2"
|
||||
assert html =~ "Confirmed"
|
||||
end
|
||||
end
|
||||
|
||||
describe "subscribers tab" do
|
||||
test "lists subscribers", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "listed@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
assert has_element?(view, "#subscribers")
|
||||
assert render(view) =~ "listed@example.com"
|
||||
end
|
||||
|
||||
test "filters by status", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "confirmed@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
# Filter to confirmed only
|
||||
view |> element("button", "Confirmed") |> render_click()
|
||||
|
||||
assert render(view) =~ "confirmed@example.com"
|
||||
end
|
||||
|
||||
test "searches subscribers", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "findme@example.com")
|
||||
confirmed_subscriber_fixture(email: "other@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
view |> element("form") |> render_change(%{search: "findme"})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "findme@example.com"
|
||||
refute html =~ "other@example.com"
|
||||
end
|
||||
|
||||
test "deletes subscriber", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "deleteme@example.com")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||
|
||||
assert render(view) =~ "deleteme@example.com"
|
||||
|
||||
view |> element("button", "Delete") |> render_click()
|
||||
|
||||
refute render(view) =~ "deleteme@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "campaigns tab" do
|
||||
test "lists campaigns", %{conn: conn} do
|
||||
campaign_fixture(subject: "Test campaign")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||
|
||||
assert render(view) =~ "Test campaign"
|
||||
end
|
||||
|
||||
test "deletes draft campaign", %{conn: conn} do
|
||||
campaign_fixture(subject: "Delete me")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||
|
||||
assert render(view) =~ "Delete me"
|
||||
|
||||
view |> element("button", "Delete") |> render_click()
|
||||
|
||||
refute render(view) =~ "Delete me"
|
||||
end
|
||||
|
||||
test "links to new campaign form", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||
|
||||
assert has_element?(view, "a[href='/admin/newsletter/campaigns/new']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /admin/newsletter/export" do
|
||||
test "downloads CSV of subscribers", %{conn: conn} do
|
||||
confirmed_subscriber_fixture(email: "export@example.com")
|
||||
|
||||
conn = get(conn, ~p"/admin/newsletter/export")
|
||||
|
||||
assert response_content_type(conn, :csv) =~ "text/csv"
|
||||
assert get_resp_header(conn, "content-disposition") |> hd() =~ "attachment"
|
||||
assert conn.resp_body =~ "export@example.com"
|
||||
assert conn.resp_body =~ "email,status"
|
||||
end
|
||||
end
|
||||
end
|
||||
40
test/support/fixtures/newsletter_fixtures.ex
Normal file
40
test/support/fixtures/newsletter_fixtures.ex
Normal file
@ -0,0 +1,40 @@
|
||||
defmodule Berrypod.NewsletterFixtures do
|
||||
@moduledoc """
|
||||
Test helpers for creating newsletter subscribers and campaigns.
|
||||
"""
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
def unique_email, do: "user#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
@doc """
|
||||
Creates a confirmed subscriber directly via Repo, bypassing the
|
||||
confirmation email flow.
|
||||
"""
|
||||
def confirmed_subscriber_fixture(attrs \\ %{}) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
email: unique_email(),
|
||||
status: "confirmed",
|
||||
confirmed_at: now,
|
||||
consent_text: "Test signup"
|
||||
})
|
||||
|
||||
%Newsletter.Subscriber{}
|
||||
|> Newsletter.Subscriber.changeset(attrs)
|
||||
|> Berrypod.Repo.insert!()
|
||||
end
|
||||
|
||||
def campaign_fixture(attrs \\ %{}) do
|
||||
attrs =
|
||||
Enum.into(attrs, %{
|
||||
subject: "Test campaign #{System.unique_integer([:positive])}",
|
||||
body: "Hello! Unsubscribe: {{unsubscribe_url}}"
|
||||
})
|
||||
|
||||
{:ok, campaign} = Newsletter.create_campaign(attrs)
|
||||
campaign
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user