diff --git a/README.md b/README.md index 1d43d03..3a40eb7 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,27 @@ Whoami is a modern, self-hosted personal profile and portfolio platform built with **Rails 8**, **Hotwire**, and **Tailwind CSS**. It lets you share your profile, links, CV/experience, and blog posts β€” all in a sleek, SEO-optimized interface. - - --- ## ✨ Features -- πŸ” **Authentication** with Devise (email confirmation included) -- 🎨 **Customizable profile** with name, avatar, bio, and links -- πŸ”— **Favorite links** with real-time click tracking -- πŸ“„ **Experience / CV** timeline +- πŸ” **Authentication** with Devise (email confirmation included) +- 🎨 **Customizable profile** with name, avatar, bio, and links +- πŸ”— **Favorite links** with real-time click tracking +- πŸ“„ **Experience / CV** timeline - ✍️ **Blogging system** with rich text editor -- πŸ“Š **Dashboard** with live stats (profile views, link clicks, blog reads) -- 🌍 **Public profiles** optimized for SEO (title/meta tags, slugs with FriendlyId) -- πŸ“± Fully responsive, modern UI with Tailwind and custom theme (dark + accent color) +- πŸ“Š **Dashboard** with live stats (profile views, link clicks, blog reads) +- 🌍 **Public profiles** optimized for SEO (title/meta tags, slugs with FriendlyId) +- πŸ“± Fully responsive, modern UI with Tailwind and custom theme (dark + accent color) +- πŸ“° **RSS Feed** for every user’s blog posts (`/:username/feed`) +- πŸ“§ **Newsletter subscriptions**: visitors can subscribe to your profile and get your published posts delivered automatically via email --- ## πŸš€ Getting Started ### Prerequisites + - Ruby 3.4+ - Rails 8 - SQLite @@ -31,22 +32,26 @@ It lets you share your profile, links, CV/experience, and blog posts β€” all in ### Setup Clone the repository: + ```bash -git clone https://github.com/YOUR_USERNAME/whoami.git +git clone https://github.com/s1lvax/whoami.git cd whoami ``` Install dependencies: + ```bash bundle install ``` Setup the database: + ```bash bin/rails db:prepare ``` Run the server: + ```bash bin/dev ``` @@ -57,9 +62,10 @@ Visit: [http://localhost:3000](http://localhost:3000) ## πŸ–₯️ Deployment -Whoami uses [Kamal](https://kamal-deploy.org) for zero-downtime Docker deployments. +Whoami uses [Kamal](https://kamal-deploy.org) for zero-downtime Docker deployments. Deploy with: + ```bash bin/kamal deploy ``` @@ -78,9 +84,9 @@ bin/kamal deploy ## πŸ“Š Stats Tracking -- Profile visits (ignores self and spammy repeat hits) -- Link clicks (safe + unique tracking) -- Blog post views +- Profile visits (ignores self and spammy repeat hits) +- Link clicks (safe + unique tracking) +- Blog post views --- @@ -99,5 +105,4 @@ MIT License. See [LICENSE](LICENSE) for details. ## πŸ‘€ Author -Made with ❀️ by me (https://whoami.tech/cfds) - +Made with ❀️ by me () diff --git a/app/components/dashboard/post_form_component.html.erb b/app/components/dashboard/post_form_component.html.erb index 20fcbf8..2c5679d 100644 --- a/app/components/dashboard/post_form_component.html.erb +++ b/app/components/dashboard/post_form_component.html.erb @@ -6,267 +6,483 @@ <%= form_with model: post, url: submit_path, method: submit_method, data: { turbo: true } do |f| %>
- <%= render InputComponent.new(form: f, field: :title, type: :text, label: "Title", autofocus: true) %> - <%= render InputComponent.new(form: f, field: :excerpt, type: :text, label: "Excerpt (optional)") %> + <%= render InputComponent.new( + form: f, + field: :title, + type: :text, + label: "Title", + autofocus: true, + ) %> + <%= render InputComponent.new( + form: f, + field: :excerpt, + type: :text, + label: "Excerpt (optional)", + ) %>
- + - - -
-
- - <%= f.rich_text_area :body, - toolbar: toolbar_id, - class: "max-w-none min-h-[20rem] trix-content", - data: { behavior: "post-body" } %> + toolbar: toolbar_id, + class: "max-w-none min-h-[20rem] trix-content", + data: { + behavior: "post-body", + } %> <% if post.errors[:body].any? %>

<%= post.errors[:body].first %>

<% end %>
+
+ <%= f.check_box :send_to_newsletter, + class: + "rounded border-gray-300 text-[var(--btn-bg)] focus:ring-[var(--btn-bg)]" %> + <%= f.label :send_to_newsletter, + "Send this post to newsletter subscribers (will only send if it's not a draft)", + class: "text-sm text-[var(--muted)]" %> +
+
- <%= f.select :status, Post::STATUSES.map { |s| [s.titleize, s] }, {}, - class: "w-full rounded-md px-3 py-2 bg-[var(--input-bg)] ring-1 ring-[var(--input-border)] text-sm" %> + <%= f.select :status, + Post::STATUSES.map { |s| [s.titleize, s] }, + {}, + class: + "w-full rounded-md px-3 py-2 bg-[var(--input-bg)] ring-1 ring-[var(--input-border)] text-sm" %>
<% if post.errors[:base].any? %> @@ -274,8 +490,10 @@ <% end %>
- <%= link_to "Cancel", dashboard_path, - class: "inline-flex items-center rounded-md px-4 py-2 text-sm ring-1 ring-[var(--border)] bg-[var(--surface)] hover:bg-[var(--surface-2)]" %> + <%= link_to "Cancel", + dashboard_path, + class: + "inline-flex items-center rounded-md px-4 py-2 text-sm ring-1 ring-[var(--border)] bg-[var(--surface)] hover:bg-[var(--surface-2)]" %> <%= render ButtonComponent.new(type: :submit, style: :primary) do %> <%= post.persisted? ? "Save" : "Create" %> diff --git a/app/components/input_component.html.erb b/app/components/input_component.html.erb index 4f99ace..a8b13e4 100644 --- a/app/components/input_component.html.erb +++ b/app/components/input_component.html.erb @@ -1,19 +1,24 @@ - <% error_text = error_text_for(@field) %>
- <%= @form.label @field, @label, class: "block text-sm font-medium text-[var(--muted)] mb-1" %> + <%= @form.label @field, + @label, + class: "block text-sm font-medium text-[var(--muted)] mb-1" %> <% base_class = [ - "w-full rounded-md px-3 py-2 focus:outline-none", - "bg-[var(--input-bg)] text-[var(--input-text)] placeholder-[var(--input-placeholder)]", - "ring-1" - ].join(" ") %> - - <% ring_class = error_text.present? ? - "ring-[var(--danger)] focus:ring-2 focus:ring-[var(--danger)]" : - "ring-[var(--input-border)] focus:ring-2 focus:ring-[var(--btn-ring)]" - %> + "w-full rounded-md px-3 py-2 focus:outline-none", + "bg-[var(--input-bg)] text-[var(--input-text)] placeholder-[var(--input-placeholder)]", + "ring-1", + ].join(" ") %> + + <% ring_class = + ( + if error_text.present? + "ring-[var(--danger)] focus:ring-2 focus:ring-[var(--danger)]" + else + "ring-[var(--input-border)] focus:ring-2 focus:ring-[var(--btn-ring)]" + end + ) %> <% extra_class = @input_options.delete(:class) %> <% merged_class = [base_class, ring_class, extra_class].compact.join(" ") %> @@ -22,11 +27,12 @@ <% merged_data = {}.merge(extra_data) %> <% opts = { - autocomplete: @autocomplete, - autofocus: @autofocus, - class: merged_class, - data: merged_data - }.merge(@input_options) %> + autocomplete: @autocomplete, + autofocus: @autofocus, + placeholder: @placeholder, + class: merged_class, + data: merged_data, + }.merge(@input_options) %> <%# Only set value if explicitly provided %> <% opts[:value] = @value if @value.present? %> diff --git a/app/components/input_component.rb b/app/components/input_component.rb index f47ef84..2840f4f 100644 --- a/app/components/input_component.rb +++ b/app/components/input_component.rb @@ -1,8 +1,9 @@ class InputComponent < ViewComponent::Base - def initialize(form:, field:, type:, label: nil, autocomplete: nil, autofocus: false, value: nil, hint: nil, input_options: {}) + def initialize(form:, field:, type:, placeholder: nil, label: nil, autocomplete: nil, autofocus: false, value: nil, hint: nil, input_options: {}) @form = form @field = field @type = type + @placeholder = placeholder @label = label @autocomplete = autocomplete @autofocus = autofocus diff --git a/app/components/public_profile/newsletter_subscription_component.html.erb b/app/components/public_profile/newsletter_subscription_component.html.erb new file mode 100644 index 0000000..e7e3b38 --- /dev/null +++ b/app/components/public_profile/newsletter_subscription_component.html.erb @@ -0,0 +1,31 @@ +
+

+ Subscribe to + <%= user.username %> +

+ + <% if subscription&.errors&.any? %> +
+ <% subscription.errors.full_messages.each do |msg| %> +

<%= msg %>

+ <% end %> +
+ <% end %> + + <%= form_with( + url: new_subscription_path(username: user.username), + method: :post, + scope: :subscription, + class: "flex flex-col sm:flex-row justify-center gap-2 max-w-md mx-auto" + ) do |f| %> + <%= f.email_field :subscriber_email, + placeholder: "Enter your email", + required: true, + class: + "flex-1 rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--btn-bg)]" %> + + <%= f.submit "Subscribe", + class: + "rounded-md bg-[var(--btn-bg)] text-black cursor-pointer px-4 py-2 text-sm font-medium hover:opacity-90 transition" %> + <% end %> +
diff --git a/app/components/public_profile/newsletter_subscription_component.rb b/app/components/public_profile/newsletter_subscription_component.rb new file mode 100644 index 0000000..f134c9c --- /dev/null +++ b/app/components/public_profile/newsletter_subscription_component.rb @@ -0,0 +1,10 @@ +class PublicProfile::NewsletterSubscriptionComponent < ViewComponent::Base + def initialize(user:, subscription: nil) + @user = user + @subscription = subscription + end + + private + + attr_reader :user, :subscription +end diff --git a/app/controllers/dashboard/posts_controller.rb b/app/controllers/dashboard/posts_controller.rb index 6ff5e78..83e5edc 100644 --- a/app/controllers/dashboard/posts_controller.rb +++ b/app/controllers/dashboard/posts_controller.rb @@ -45,6 +45,6 @@ def set_post end def post_params - params.require(:post).permit(:title, :excerpt, :status, :slug, :body) + params.require(:post).permit(:title, :send_to_newsletter, :excerpt, :status, :slug, :body) end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 8c0a416..af46c22 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -13,11 +13,13 @@ def show visits = @user.visits.to_i link_clicks = @user.favorite_links.sum(:clicks) rescue 0 # assumes favorite_links has :clicks blog_reads = @user.posts.sum(:views) rescue 0 + subscribers = @user.subscriptions.confirmed.count @stats = [ { label: "Profile Views", value: helpers.number_with_delimiter(visits), delta: nil, up: nil }, { label: "Link Clicks", value: helpers.number_with_delimiter(link_clicks), delta: nil, up: nil }, - { label: "Blog Reads", value: helpers.number_with_delimiter(blog_reads), delta: nil, up: nil } + { label: "Blog Reads", value: helpers.number_with_delimiter(blog_reads), delta: nil, up: nil }, + { label: "Newsletter Subscribers", value: helpers.number_with_delimiter(subscribers), delta: nil, up: nil } ] @experiences = @user.experiences.order(start_date: :desc) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index baed55a..77215bd 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -1,3 +1,5 @@ class StaticController < ApplicationController def confirmation_sent; end + + def subscription_sent; end end diff --git a/app/controllers/subscription_controller.rb b/app/controllers/subscription_controller.rb new file mode 100644 index 0000000..2324fcd --- /dev/null +++ b/app/controllers/subscription_controller.rb @@ -0,0 +1,80 @@ +class SubscriptionController < ApplicationController + before_action :set_user + before_action :set_token, only: %w[confirm cancel] + + def subscribe + @subscription = @user.subscriptions.new(subscription_params) + + if @subscription.save + SubscriptionMailer.with( + token: @subscription.token, + email: @subscription.subscriber_email, + username: @user.username + ).confirm.deliver_later + end + + # Always redirect here, regardless of outcome + redirect_to subscription_sent_path(username: @user.username) + end + + def confirm + subscription = Subscription.find_by(token: @token) + + if subscription&.confirmed + redirect_to public_profile_path(subscription.user.username), + notice: "Subscription has already been confirmed!" + return + end + + if subscription&.update(confirmed: true, confirmed_at: Time.current) + SubscriptionMailer.with( + token: subscription.token, + email: subscription.subscriber_email, + username: @user.username + ).welcome.deliver_later + + redirect_to public_profile_path(subscription.user.username), + notice: "Subscription has been confirmed!" + else + redirect_to subscription_sent_path(username: @user.username), + alert: "Something went wrong. Please try again." + end + end + + def cancel + subscription = Subscription.find_by(token: @token) + + unless subscription + redirect_to public_profile_path(@user.username), + alert: "This subscription is no longer valid." + return + end + + if subscription.destroy + SubscriptionMailer.with( + email: subscription.subscriber_email, + username: @user.username + ).unsubscribe.deliver_later + + redirect_to public_profile_path(subscription.user.username), + notice: "Subscription has been deleted!" + else + redirect_to public_profile_path(subscription.user.username), + alert: "Something went wrong. Please try again." + end + end + + private + + def subscription_params + params.require(:subscription).permit(:subscriber_email) + end + + def set_token + @token = params[:token] + end + + def set_user + @user = User.find_by!(username: params[:username].downcase) + end +end diff --git a/app/helpers/subscription_helper.rb b/app/helpers/subscription_helper.rb new file mode 100644 index 0000000..bac8d5a --- /dev/null +++ b/app/helpers/subscription_helper.rb @@ -0,0 +1,2 @@ +module SubscriptionHelper +end diff --git a/app/jobs/newsletter_broadcast_job.rb b/app/jobs/newsletter_broadcast_job.rb new file mode 100644 index 0000000..d195a6c --- /dev/null +++ b/app/jobs/newsletter_broadcast_job.rb @@ -0,0 +1,19 @@ +class NewsletterBroadcastJob < ApplicationJob + queue_as :default + + def perform(post_id) + post = Post.find(post_id) + return unless post.published? && post.send_to_newsletter? && !post.newsletter_sent? + + post.user.subscriptions.confirmed.find_each do |subscription| + SubscriptionMailer.with( + post: post, + email: subscription.subscriber_email, + username: post.user.username, + token: subscription.token + ).broadcast_post.deliver_later + end + + post.update!(newsletter_sent: true) + end +end diff --git a/app/mailers/subscription_mailer.rb b/app/mailers/subscription_mailer.rb new file mode 100644 index 0000000..6113b57 --- /dev/null +++ b/app/mailers/subscription_mailer.rb @@ -0,0 +1,55 @@ +class SubscriptionMailer < ApplicationMailer + def confirm + @token = params[:token] + @username = params[:username] + @email = params[:email] + + mail( + to: @email, + subject: "Confirm your new subscription to #{@username}", + track_opens: true + ) + end + + def welcome + @token = params[:token] + @username = params[:username] + @email = params[:email] + + mail( + to: @email, + subject: "Your subscription to #{@username}", + track_opens: true + ) + end + + def unsubscribe + @username = params[:username] + @email = params[:email] + + mail( + to: @email, + subject: "You unsubscribed from #{@username}", + track_opens: true + ) + end + + def broadcast_post + @post = params[:post] + @username = params[:username] + @token = params[:token] + @email = params[:email] + + mail( + to: @email, + subject: "#{@username} just published: #{@post.title}", + track_opens: true + ) + end + + private + + def default_host + Rails.application.config.action_mailer.default_url_options&.dig(:host) + end +end diff --git a/app/models/post.rb b/app/models/post.rb index 842c73a..4d177ba 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,5 +1,6 @@ class Post < ApplicationRecord extend FriendlyId + belongs_to :user has_rich_text :body @@ -18,8 +19,12 @@ class Post < ApplicationRecord before_validation :trim_fields before_save :sync_published_at + # enqueue newsletter sending when user creates post or modifies it from draft to published + after_commit :enqueue_newsletter_broadcast, on: [ :create, :update ] + scope :latest, -> { order(Arel.sql("COALESCE(published_at, updated_at) DESC")) } scope :published, -> { where(status: "published") } + def published? = status == "published" def should_generate_new_friendly_id? @@ -41,6 +46,14 @@ def sync_published_at end end + def enqueue_newsletter_broadcast + return unless published? && send_to_newsletter? && !newsletter_sent? + + if user.subscriptions.confirmed.exists? + NewsletterBroadcastJob.perform_later(id) + end + end + def body_attachments_are_images_and_small return unless body&.body diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 0000000..358aad0 --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,12 @@ +class Subscription < ApplicationRecord + has_secure_token :token + + belongs_to :user + + validates :subscriber_email, presence: true + + validates :subscriber_email, uniqueness: { scope: :user_id, message: "is already subscribed" } + + # scope to only get confirmed subscribtptions + scope :confirmed, -> { where(confirmed: true) } +end diff --git a/app/models/user.rb b/app/models/user.rb index 6af630b..503ac8e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,6 +14,9 @@ class User < ApplicationRecord # posts has_many :posts, dependent: :destroy + # subscriptions + has_many :subscriptions, dependent: :destroy + # sets username to downcase before validation before_validation :downcase_username diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb index cb4e886..f11adb5 100644 --- a/app/views/dashboard/show.html.erb +++ b/app/views/dashboard/show.html.erb @@ -9,12 +9,12 @@
<%= link_to "Account Settings", - edit_registration_path(current_user), + edit_registration_path(current_user), class: "inline-flex items-center rounded-md px-4 py-2 ring-1 ring-[var(--border)] bg-[var(--surface)] hover:bg-[var(--surface-2)] text-sm" %> <%= link_to "View Profile", - public_profile_path(current_user.username), + public_profile_path(current_user.username), class: "inline-flex items-center rounded-md px-4 py-2 ring-1 ring-[var(--border)] bg-[var(--surface)] hover:bg-[var(--surface-2)] text-sm" %>
@@ -22,13 +22,13 @@
<%= turbo_frame_tag "profile_header" do %> <%= render Dashboard::ProfileHeaderComponent.new( - user: @user, - edit_href: edit_dashboard_path - ) %> + user: @user, + edit_href: edit_dashboard_path, + ) %> <% end %>
-
+
<% @stats.each do |s| %> <%= render Dashboard::StatCardComponent.new(**s) %> <% end %> @@ -55,42 +55,41 @@
<% end %> <%= render Dashboard::SectionComponent.new(title: "Latest Posts", action_label: "Write", action_href: new_dashboard_post_path) do %> -
- - - - - - - +
+
TitleDateViewsStatus
+ + + + + + + + + + <% @posts.each do |post| %> + + + + + - - - <% @posts.each do |post| %> - - - - - - - <% end %> - -
TitleDateViewsStatus
+ <%= link_to post.title, dashboard_post_path(post), class: "underline" %> + + <%= (post.published_at || post.updated_at).to_date.to_fs(:long) %> + <%= post.views %> + + <%= post.status.titleize %> + +
- <%= link_to post.title, dashboard_post_path(post), class: "underline" %> - - <%= (post.published_at || post.updated_at).to_date.to_fs(:long) %> - <%= post.views %> - - <%= post.status.titleize %> - -
-
- - -
- <%= render PaginationComponent.new(pagy: @pagy) %> -
- <% end %> + <% end %> + + +
+ +
+ <%= render PaginationComponent.new(pagy: @pagy) %> +
+ <% end %>
diff --git a/app/views/public_posts/show.html.erb b/app/views/public_posts/show.html.erb index 9b42c52..891e977 100644 --- a/app/views/public_posts/show.html.erb +++ b/app/views/public_posts/show.html.erb @@ -53,6 +53,11 @@
<% end %> + <%= render PublicProfile::NewsletterSubscriptionComponent.new( + user: @user, + subscription: @subscription, + ) %> + <%# -------- Post body (ActionText / Trix) -------- %> <% cache ["public_post:body", @post.cache_key_with_version] do %>
diff --git a/app/views/static/subscription_sent.html.erb b/app/views/static/subscription_sent.html.erb new file mode 100644 index 0000000..b04f3cf --- /dev/null +++ b/app/views/static/subscription_sent.html.erb @@ -0,0 +1,13 @@ +
+
+

Confirm your subscription

+

+ We’ve sent a confirmation link to your email. Click it to activate your + subscription to this user's newsletter. +

+
+
diff --git a/app/views/subscription_mailer/broadcast_post.html.erb b/app/views/subscription_mailer/broadcast_post.html.erb new file mode 100644 index 0000000..8c2c176 --- /dev/null +++ b/app/views/subscription_mailer/broadcast_post.html.erb @@ -0,0 +1,17 @@ + +
+ <%= @post.body.to_s.html_safe %> +
+ +
+ +

+ <%= link_to "View this post online", public_post_url(@username, @post) %> +

+ +

+ You received this because you're subscribed to + <%= @username %>.
+ Don’t want these posts? + <%= link_to "Unsubscribe", cancel_subscription_url(@username, @token) %>. +

diff --git a/app/views/subscription_mailer/broadcast_post.text.erb b/app/views/subscription_mailer/broadcast_post.text.erb new file mode 100644 index 0000000..d6e8779 --- /dev/null +++ b/app/views/subscription_mailer/broadcast_post.text.erb @@ -0,0 +1,6 @@ +<%= @post.body.to_plain_text %> +--- View this post online: +<%= public_post_url(@username, @post) %> +You received this because you're subscribed to +<%= @username %>. Don’t want these posts? Unsubscribe here: +<%= cancel_subscription_url(@username, @token) %> diff --git a/app/views/subscription_mailer/confirm.html.erb b/app/views/subscription_mailer/confirm.html.erb new file mode 100644 index 0000000..f3e8f29 --- /dev/null +++ b/app/views/subscription_mailer/confirm.html.erb @@ -0,0 +1,84 @@ + + + + + Confirm your subscription + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+

+ Confirm your subscription +

+
+

Hello,

+

+ You requested to subscribe to + <%= @username %>. Please confirm your subscription by clicking the button + below: +

+
+ + Confirm Subscription + +
+

+ If you didn’t request this subscription, you can safely + ignore this email. +

+
+ This email was sent to + <%= @email %>. +
+
+ + + diff --git a/app/views/subscription_mailer/confirm.text.erb b/app/views/subscription_mailer/confirm.text.erb new file mode 100644 index 0000000..94dfda6 --- /dev/null +++ b/app/views/subscription_mailer/confirm.text.erb @@ -0,0 +1,8 @@ +Confirm your subscription Hello, You requested to subscribe to +<%= @username %> +. Please confirm your subscription by visiting the link below: +<%= confirm_subscription_url(@username, @token) %> +If you didn’t request this subscription, you can safely ignore this email. --- +This email was sent to +<%= @email %>. + diff --git a/app/views/subscription_mailer/unsubscribe.html.erb b/app/views/subscription_mailer/unsubscribe.html.erb new file mode 100644 index 0000000..f76d03f --- /dev/null +++ b/app/views/subscription_mailer/unsubscribe.html.erb @@ -0,0 +1,69 @@ + + + + + Unsubscribed from + <%= @username %> + + + + + + +
+ + + + + + + + + + + + + + +
+

+ Your subscription to + <%= @username %> +

+
+

Hello,

+

+ You are now unsubscribed from + <%= @username %>. You'll no longer receive any emails from this user. +

+

+ If you ever decide to resubscribe, just enter your email + again on one of + <%= @username %>'s posts. +

+
+ This email was sent to + <%= @email %>. +
+
+ + diff --git a/app/views/subscription_mailer/unsubscribe.text.erb b/app/views/subscription_mailer/unsubscribe.text.erb new file mode 100644 index 0000000..6174d0c --- /dev/null +++ b/app/views/subscription_mailer/unsubscribe.text.erb @@ -0,0 +1,6 @@ +Your subscription to +<%= @username %> +Hello, You are now unsubscribed from +<%= @username %>. You'll no longer receive any emails from this user. If you ever decide to +resubscribe, just enter your email again in one of +<%= @username %>'s posts. diff --git a/app/views/subscription_mailer/welcome.html.erb b/app/views/subscription_mailer/welcome.html.erb new file mode 100644 index 0000000..8f0717f --- /dev/null +++ b/app/views/subscription_mailer/welcome.html.erb @@ -0,0 +1,82 @@ + + + + + Welcome to + <%= @username %>’s newsletter + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+

+ Your subscription to + <%= @username %> +

+
+

Hello,

+

+ You are now subscribed to + <%= @username %>. You'll receive all new posts that + <%= @username %> + chooses to share with their email list πŸŽ‰ +

+

+ If you'd like to unsubscribe at any time, simply click the + link below: +

+
+ + Unsubscribe + +
+ This email was sent to + <%= @email %>. +
+
+ + + diff --git a/app/views/subscription_mailer/welcome.text.erb b/app/views/subscription_mailer/welcome.text.erb new file mode 100644 index 0000000..c4f75d1 --- /dev/null +++ b/app/views/subscription_mailer/welcome.text.erb @@ -0,0 +1,9 @@ +Your subscription to +<%= @username %> +Hello, You are now subscribed to +<%= @username %>. You'll receive all new posts that +<%= @username %> +chooses to share with their email list. If you'd like to unsubscribe, please +follow this link: +<%= cancel_subscription_url(@username, @token) %> + diff --git a/config/routes.rb b/config/routes.rb index d272446..dd08c53 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,9 @@ # public confirm email page get "/confirmation-sent", to: "static#confirmation_sent", as: :confirmation_sent + # public confirm subscription page + get "/:username/confirmation-sent", to: "static#subscription_sent", as: :subscription_sent + get "up" => "rails/health#show", as: :rails_health_check # Policies @@ -45,6 +48,11 @@ get "/:username/links/:id/click", to: "public_links#click", as: :public_link_click + # subscription logic + post "/:username/subscribe", to: "subscription#subscribe", as: :new_subscription + get "/:username/:token/confirm", to: "subscription#confirm", as: :confirm_subscription + get "/:username/:token/cancel", to: "subscription#cancel", as: :cancel_subscription + get "/:username", to: "profiles#show", as: :public_profile, constraints: ->(req) { u = req.params[:username].to_s diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 0f651a4..d7fe776 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -10,5 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 0) do +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end end diff --git a/db/cache_schema.rb b/db/cache_schema.rb index d2d5e90..fc99b30 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -10,9 +10,15 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_03_205709) do - create_table "solid_cache_tables", force: :cascade do |t| +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true end end diff --git a/db/migrate/20250916084728_create_subscriptions.rb b/db/migrate/20250916084728_create_subscriptions.rb new file mode 100644 index 0000000..6990c27 --- /dev/null +++ b/db/migrate/20250916084728_create_subscriptions.rb @@ -0,0 +1,15 @@ +class CreateSubscriptions < ActiveRecord::Migration[8.0] + def change + create_table :subscriptions do |t| + t.belongs_to :user, null: false, foreign_key: true + t.string :subscriber_email + t.string :token + t.boolean :confirmed, default: false + t.datetime :confirmed_at + t.boolean :canceled, default: false + t.datetime :canceled_at + + t.timestamps + end + end +end diff --git a/db/migrate/20250916121422_add_newsletter_option_to_posts.rb b/db/migrate/20250916121422_add_newsletter_option_to_posts.rb new file mode 100644 index 0000000..5fc9f74 --- /dev/null +++ b/db/migrate/20250916121422_add_newsletter_option_to_posts.rb @@ -0,0 +1,5 @@ +class AddNewsletterOptionToPosts < ActiveRecord::Migration[8.0] + def change + add_column :posts, :send_to_newsletter, :boolean, default: false + end +end diff --git a/db/migrate/20250916122820_add_newsletter_sent_to_posts.rb b/db/migrate/20250916122820_add_newsletter_sent_to_posts.rb new file mode 100644 index 0000000..50d1944 --- /dev/null +++ b/db/migrate/20250916122820_add_newsletter_sent_to_posts.rb @@ -0,0 +1,5 @@ +class AddNewsletterSentToPosts < ActiveRecord::Migration[8.0] + def change + add_column :posts, :newsletter_sent, :boolean, default: false, null: false + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 0f651a4..4b2cdcd 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -10,5 +10,132 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 0) do +ActiveRecord::Schema[8.0].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade end diff --git a/db/schema.rb b/db/schema.rb index bdf2c5f..f2a645a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_25_140855) do +ActiveRecord::Schema[8.0].define(version: 2025_09_16_122820) do create_table "action_text_rich_texts", force: :cascade do |t| t.string "name", null: false t.text "body" @@ -97,11 +97,26 @@ t.datetime "published_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "send_to_newsletter" + t.boolean "newsletter_sent", default: false, null: false t.index ["slug"], name: "index_posts_on_slug", unique: true t.index ["user_id", "status", "published_at"], name: "index_posts_on_user_id_and_status_and_published_at" t.index ["user_id"], name: "index_posts_on_user_id" end + create_table "subscriptions", force: :cascade do |t| + t.integer "user_id", null: false + t.string "subscriber_email" + t.string "token" + t.boolean "confirmed", default: false + t.datetime "confirmed_at" + t.boolean "canceled", default: false + t.datetime "canceled_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_subscriptions_on_user_id" + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -132,4 +147,5 @@ add_foreign_key "experiences", "users" add_foreign_key "favorite_links", "users" add_foreign_key "posts", "users" + add_foreign_key "subscriptions", "users" end diff --git a/test/components/dashboard/post_form_component_test.rb b/test/components/dashboard/post_form_component_test.rb index 3bde45d..40a62fa 100644 --- a/test/components/dashboard/post_form_component_test.rb +++ b/test/components/dashboard/post_form_component_test.rb @@ -38,6 +38,17 @@ def render_fragment(post:, submit_path:, submit_method:) Nokogiri::HTML.fragment(html) end # -------------------------------- + # + test "renders send_to_newsletter checkbox with label" do + frag = render_fragment(post: Post.new(status: :draft), submit_path: "/dashboard/posts", submit_method: :post) + + checkbox = frag.at_css('input[type="checkbox"][name="post[send_to_newsletter]"]') + assert checkbox, "expected send_to_newsletter checkbox" + + label = frag.at_css('label[for="post_send_to_newsletter"]') + assert label, "expected label for send_to_newsletter checkbox" + assert_match(/Send this post to newsletter subscribers/i, label.text) + end test "renders New Post title for new record and Edit Post for persisted" do new_post = Post.new(status: :draft) diff --git a/test/components/public_profile/newsletter_subscription_component_test.rb b/test/components/public_profile/newsletter_subscription_component_test.rb new file mode 100644 index 0000000..e40fd72 --- /dev/null +++ b/test/components/public_profile/newsletter_subscription_component_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class PublicProfile::NewsletterSubscriptionComponentTest < ViewComponent::TestCase + include Rails.application.routes.url_helpers + fixtures :users + + def setup + @user = users(:one) + end + + test "renders subscription form for a user" do + render_inline(PublicProfile::NewsletterSubscriptionComponent.new(user: @user)) + + assert_selector "h2", text: /Subscribe to\s+#{@user.username}/ + assert_selector "form[action='#{new_subscription_path(username: @user.username)}']" + assert_selector "input[type='email'][name='subscription[subscriber_email]']" + assert_selector "input[type='submit'][value='Subscribe']" + end + + test "renders error messages if subscription has errors" do + subscription = @user.subscriptions.new # invalid, no email + subscription.validate # forces errors + + render_inline(PublicProfile::NewsletterSubscriptionComponent.new(user: @user, subscription: subscription)) + + assert_selector ".text-red-500", text: "can't be blank" + end +end diff --git a/test/controllers/dashboard_controller_test.rb b/test/controllers/dashboard_controller_test.rb index 301dd3d..9c32b70 100644 --- a/test/controllers/dashboard_controller_test.rb +++ b/test/controllers/dashboard_controller_test.rb @@ -32,6 +32,7 @@ class DashboardControllerTest < ActionDispatch::IntegrationTest assert_match "Profile Views", response.body assert_match "Link Clicks", response.body assert_match "Blog Reads", response.body + assert_match "Newsletter Subscribers", response.body end test "renders edit form" do diff --git a/test/controllers/subscription_controller_test.rb b/test/controllers/subscription_controller_test.rb new file mode 100644 index 0000000..676bea7 --- /dev/null +++ b/test/controllers/subscription_controller_test.rb @@ -0,0 +1,68 @@ +require "test_helper" + +class SubscriptionControllerTest < ActionDispatch::IntegrationTest + fixtures :users + + setup do + @user = users(:one) + end + + test "should create subscription and send confirmation email" do + assert_enqueued_emails 1 do + post new_subscription_path(username: @user.username), params: { + subscription: { subscriber_email: "test@example.com" } + } + end + + assert_redirected_to subscription_sent_path(username: @user.username) + assert Subscription.exists?(subscriber_email: "test@example.com", user: @user) + end + + test "should not create duplicate subscription" do + @user.subscriptions.create!(subscriber_email: "dupe@example.com") + + assert_no_difference "Subscription.count" do + post new_subscription_path(username: @user.username), params: { + subscription: { subscriber_email: "dupe@example.com" } + } + end + + assert_redirected_to subscription_sent_path(username: @user.username) + end + + test "should confirm subscription and send welcome email" do + subscription = @user.subscriptions.create!(subscriber_email: "confirm@example.com") + + assert_enqueued_emails 1 do + get confirm_subscription_path(username: @user.username, token: subscription.token) + end + + assert_redirected_to public_profile_path(@user.username) + assert subscription.reload.confirmed + end + + test "should not confirm with invalid token" do + get confirm_subscription_path(username: @user.username, token: "wrongtoken") + + assert_redirected_to subscription_sent_path(username: @user.username) + assert_equal "Something went wrong. Please try again.", flash[:alert] + end + + test "should cancel subscription and send unsubscribe email" do + subscription = @user.subscriptions.create!(subscriber_email: "bye@example.com", confirmed: true) + + assert_enqueued_emails 1 do + get cancel_subscription_path(username: @user.username, token: subscription.token) + end + + assert_redirected_to public_profile_path(@user.username) + refute Subscription.exists?(id: subscription.id) + end + + test "should handle cancel with invalid token" do + get cancel_subscription_path(username: @user.username, token: "wrongtoken") + + assert_redirected_to public_profile_path(@user.username) + assert_equal "This subscription is no longer valid.", flash[:alert] + end +end diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml new file mode 100644 index 0000000..dc2515d --- /dev/null +++ b/test/fixtures/subscriptions.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + subscriber_email: MyString + token: MyString + confirmed: false + confirmed_at: 2025-09-16 10:47:28 + canceled: false + canceled_at: 2025-09-16 10:47:28 + +two: + user: two + subscriber_email: MyString + token: MyString + confirmed: false + confirmed_at: 2025-09-16 10:47:28 + canceled: false + canceled_at: 2025-09-16 10:47:28 diff --git a/test/jobs/newsletter_broadcast_job_test.rb b/test/jobs/newsletter_broadcast_job_test.rb new file mode 100644 index 0000000..1f60e71 --- /dev/null +++ b/test/jobs/newsletter_broadcast_job_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class NewsletterBroadcastJobTest < ActiveJob::TestCase + fixtures :users + include ActiveJob::TestHelper + include ActionMailer::TestHelper + + def setup + @user = users(:one) + @post = Post.create!( + user: @user, + title: "Published Post", + status: "published", + send_to_newsletter: true, + excerpt: "Job test excerpt" + ) + end + + test "enqueues mail for each confirmed subscription" do + @user.subscriptions.create!(subscriber_email: "sub1@example.com", confirmed: true, token: SecureRandom.hex(10)) + @user.subscriptions.create!(subscriber_email: "sub2@example.com", confirmed: true, token: SecureRandom.hex(10)) + + assert_enqueued_emails 2 do + NewsletterBroadcastJob.perform_now(@post.id) + end + end + + test "does nothing if post is draft" do + draft = Post.create!(user: @user, title: "Draft", status: "draft", send_to_newsletter: true) + + assert_no_enqueued_emails do + NewsletterBroadcastJob.perform_now(draft.id) + end + end + + test "does nothing if send_to_newsletter is false" do + post = Post.create!(user: @user, title: "Published Post", status: "published", send_to_newsletter: false) + + assert_no_enqueued_emails do + NewsletterBroadcastJob.perform_now(post.id) + end + end + + test "does nothing if already sent" do + @post.update!(newsletter_sent: true) + + assert_no_enqueued_emails do + NewsletterBroadcastJob.perform_now(@post.id) + end + end +end diff --git a/test/mailers/previews/subscription_mailer_preview.rb b/test/mailers/previews/subscription_mailer_preview.rb new file mode 100644 index 0000000..37dded8 --- /dev/null +++ b/test/mailers/previews/subscription_mailer_preview.rb @@ -0,0 +1,12 @@ +# Preview all emails at http://localhost:3000/rails/mailers/subscription_mailer +class SubscriptionMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/subscription_mailer/confirm + def confirm + SubscriptionMailer.confirm + end + + # Preview this email at http://localhost:3000/rails/mailers/subscription_mailer/welcome + def welcome + SubscriptionMailer.welcome + end +end diff --git a/test/mailers/subscription_mailer_test.rb b/test/mailers/subscription_mailer_test.rb new file mode 100644 index 0000000..da3ed1d --- /dev/null +++ b/test/mailers/subscription_mailer_test.rb @@ -0,0 +1,87 @@ +require "test_helper" + +class SubscriptionMailerTest < ActionMailer::TestCase + fixtures :users + + def setup + @user = users(:one) + @token = "sometoken123" + @email = "subscriber@example.com" + @post = Post.create!( + user: @user, + title: "Broadcast Title", + status: "published", + excerpt: "Sample excerpt", + body: "This is the body of the broadcast post" + ) + end + + test "confirm" do + mail = SubscriptionMailer.with( + token: @token, + username: @user.username, + email: @email + ).confirm + + assert_emails 1 do + mail.deliver_now + end + + assert_equal [ "subscriber@example.com" ], mail.to + assert_equal "Confirm your new subscription to #{@user.username}", mail.subject + assert_match @token, mail.body.encoded + assert_match @user.username, mail.body.encoded + end + + test "welcome" do + mail = SubscriptionMailer.with( + token: @token, + username: @user.username, + email: @email + ).welcome + + assert_emails 1 do + mail.deliver_now + end + + assert_equal [ "subscriber@example.com" ], mail.to + assert_equal "Your subscription to #{@user.username}", mail.subject + assert_match @user.username, mail.body.encoded + end + + test "unsubscribe" do + mail = SubscriptionMailer.with( + username: @user.username, + email: @email + ).unsubscribe + + assert_emails 1 do + mail.deliver_now + end + + assert_equal [ "subscriber@example.com" ], mail.to + assert_equal "You unsubscribed from #{@user.username}", mail.subject + assert_match @user.username, mail.body.encoded + end + + test "broadcast_post" do + mail = SubscriptionMailer.with( + post: @post, + token: @token, + username: @user.username, + email: @email + ).broadcast_post + + assert_emails 1 do + mail.deliver_now + end + + assert_equal [ "subscriber@example.com" ], mail.to + assert_equal "#{@user.username} just published: #{@post.title}", mail.subject + assert_match @post.body.to_plain_text, mail.body.encoded + assert_match @token, mail.body.encoded + assert_match @user.username, mail.body.encoded + assert_match "View this post online", mail.body.encoded + assert_match "Unsubscribe", mail.body.encoded + end +end diff --git a/test/models/post_test.rb b/test/models/post_test.rb index e54cb10..2a0ee2f 100644 --- a/test/models/post_test.rb +++ b/test/models/post_test.rb @@ -1,7 +1,7 @@ - require "test_helper" class PostTest < ActiveSupport::TestCase + include ActiveJob::TestHelper fixtures :users def setup @@ -177,4 +177,53 @@ def build_post(attrs = {}) ) assert post.valid?, -> { post.errors.full_messages.inspect } end + + # --- newsletter broadcast -------------------------------------------------- + + test "does not enqueue newsletter for draft" do + post = build_post(send_to_newsletter: true, status: "draft") + assert_no_enqueued_jobs only: NewsletterBroadcastJob do + post.save! + end + end + + test "does not enqueue newsletter if send_to_newsletter is false" do + post = build_post(status: "published", send_to_newsletter: false) + assert_no_enqueued_jobs only: NewsletterBroadcastJob do + post.save! + end + end + + test "does not enqueue newsletter if no confirmed subscriptions" do + post = build_post(status: "published", send_to_newsletter: true) + assert_no_enqueued_jobs only: NewsletterBroadcastJob do + post.save! + end + end + + test "enqueues newsletter when published, send_to_newsletter true, and confirmed subscriptions exist" do + @user.subscriptions.create!( + subscriber_email: "test@example.com", + confirmed: true, + confirmed_at: Time.current + ) + post = build_post(status: "published", send_to_newsletter: true) + + assert_enqueued_jobs 1, only: NewsletterBroadcastJob do + post.save! + end + end + + test "does not enqueue newsletter again if already sent" do + @user.subscriptions.create!( + subscriber_email: "test@example.com", + confirmed: true, + confirmed_at: Time.current + ) + post = build_post(status: "published", send_to_newsletter: true, newsletter_sent: true) + + assert_no_enqueued_jobs only: NewsletterBroadcastJob do + post.save! + end + end end diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb new file mode 100644 index 0000000..82f37b6 --- /dev/null +++ b/test/models/subscription_test.rb @@ -0,0 +1,49 @@ +require "test_helper" + +class SubscriptionTest < ActiveSupport::TestCase + fixtures :users + + def setup + @user = users(:one) + @other_user = users(:two) + end + + test "is valid with a subscriber_email and user" do + subscription = @user.subscriptions.new(subscriber_email: "test@example.com") + assert subscription.valid? + end + + test "is invalid without a subscriber_email" do + subscription = @user.subscriptions.new(subscriber_email: nil) + refute subscription.valid? + assert_includes subscription.errors[:subscriber_email], "can't be blank" + end + + test "does not allow duplicate emails per user" do + @user.subscriptions.create!(subscriber_email: "dupe@example.com") + dup = @user.subscriptions.new(subscriber_email: "dupe@example.com") + + refute dup.valid? + assert_includes dup.errors[:subscriber_email], "is already subscribed" + end + + test "allows same email to subscribe to different users" do + @user.subscriptions.create!(subscriber_email: "same@example.com") + subscription = @other_user.subscriptions.new(subscriber_email: "same@example.com") + + assert subscription.valid? + end + + test "confirmed scope returns only confirmed subscriptions" do + confirmed = @user.subscriptions.create!(subscriber_email: "confirmed@example.com", confirmed: true) + unconfirmed = @user.subscriptions.create!(subscriber_email: "pending@example.com", confirmed: false) + + assert_includes Subscription.confirmed, confirmed + refute_includes Subscription.confirmed, unconfirmed + end + + test "token is generated automatically" do + subscription = @user.subscriptions.create!(subscriber_email: "token@example.com") + assert subscription.token.present? + end +end