color: var(--standout-form-text);
}
+.reg-form h1,
+.reg-form h2,
+.reg-form h3,
+.reg-form h4 {
+ margin-top: 0;
+}
+
+.reg-form hr {
+ margin: 1em 0;
+}
+
.subheading {
margin-top: -25px;
}
:password,
:password_confirmation,
:current_password,
- :hide_ai_summaries
+ :hide_ai_summaries,
+ :digest_preference
)
end
class WeeklyBookmarksDigestJob < ApplicationJob
queue_as :default
+ retry_on StandardError, attempts: 3, wait: 5.minutes
def perform(user = nil)
- start_date = 1.week.ago.beginning_of_week
- end_date = Time.current.end_of_week
- bookmarks = Post.where(created_at: start_date..end_date)
- # or do User.find_each do |user| for everyone, but that'll break fastmail
+ end_date = Time.current.end_of_day
+ start_date = end_date - 1.week
+
+ # Convert the relation to an array of IDs
+ bookmark_ids = Post.where(created_at: start_date..end_date).pluck(:id)
+
+ if bookmark_ids.empty?
+ Rails.logger.info "No bookmarks found for period #{start_date} to #{end_date}"
+ return
+ end
+
if user.present?
- DigestMailer.weekly_bookmarks_digest(user, bookmarks).deliver_now
+ process_single_user(user, bookmark_ids)
else
- DigestMailer.weekly_bookmarks_digest(User.first, bookmarks).deliver_now
+ process_all_users(bookmark_ids)
+ end
+ end
+
+ private
+
+ def process_single_user(user, bookmark_ids)
+ if user.digest_preference_weekly_summaries?
+ Rails.logger.info "Sending weekly digest to user #{user.id}"
+ DigestMailer.weekly_bookmarks_digest(user, bookmark_ids).deliver_later
+ else
+ Rails.logger.info "User #{user.id} has disabled weekly digests"
+ end
+ end
+
+ def process_all_users(bookmark_ids)
+ user_count = User.digest_preference_weekly_summaries.count
+ Rails.logger.info "Starting weekly digest for #{user_count} users"
+
+ User.digest_preference_weekly_summaries.find_each do |user|
+ DigestMailer.weekly_bookmarks_digest(user, bookmark_ids).deliver_later
end
end
end
#
# en.digest_mailer.weekly_bookmarks_digest.subject
#
- def weekly_bookmarks_digest(user, bookmarks)
+ def weekly_bookmarks_digest(user, bookmark_ids)
@user = user
- @bookmarks = bookmarks
+ @bookmarks = Post.where(id: bookmark_ids).order(created_at: :desc)
+
mail(to: @user.email, subject: 'mind reader :: weekly digest')
end
end
attribute :buttondown_status, :string, default: 'unactivated'
attribute :support_type, :string
+ # do they want digests?
+ attribute :digest_preference, :integer, default: 0
+ enum :digest_preference, {
+ 'no_summaries' => 0,
+ 'weekly_summaries' => 1
+ }, prefix: true
+
def one_time_donor?
subscription_status == 'one_time'
end
<p class="help-text">When enabled, AI-generated summaries will not be displayed on posts.</p>
</div>
+ <% if (current_user.paid_user? == false) && (current_user.non_financial_supporter? == false) %>
+ <div class="field reg-form">
+ <%= f.label :digest_preference, "Digest email preferences" %>
+ <%= f.select :digest_preference,
+ User.digest_preferences.keys.map { |k| [k.humanize, k] },
+ {},
+ class: "form-select",
+ disabled: "disabled" %>
+ <p class="help-text">Members have access to weekly summary emails which include full-text dispatches and bookmarks. Consider <%= link_to "becoming a member", new_subscription_path %> to use this feature.</p>
+ </div>
+ <% else %>
+ <div class="field reg-form">
+ <%= f.label :digest_preference, "Digest email preferences" %>
+ <%= f.select :digest_preference,
+ User.digest_preferences.keys.map { |k| [k.humanize, k] },
+ {},
+ class: "form-select" %>
+ <p class="help-text">Please note, this is a separate email including both dispatches and bookmarks sent weekly. If you are opted-in receive dispatches via email and want to change this, you can do so <%= link_to "on this page", subscriptions_path %>.</p>
+ </div>
+ <% end %>
+
<div class="reg-form">
<div class="field">
<%= f.label :current_password %><p class="help-text">We need your current password to confirm your changes:</p>
-<p>Dear <%= @user.first_name %>,</p>
-<p>Here's what's new from mind reader this week:</p>
-<% @bookmarks.group_by { |bookmark| bookmark.created_at.to_date }.each do |date, bookmarks| %>
- <p span="dateline">On <%= date.strftime('%B %e, %Y') %>:</p>
- <ul>
- <% bookmarks.each do |bookmark| %>
- <li>
- <% url = if bookmark&.url&.present?
- bookmark.url
- else
- year = bookmark&.created_at&.year || "2000"
- "https://mndrdr.org/#{year}/#{bookmark.slug}"
- end %>
- <strong><a href="<%= url %>" target="_blank"><%= bookmark.title %></a></strong>
- <% if bookmark.content.present? %>
- <br><em><%= truncate(bookmark.content, length: 255, separator: ' ', omission: '...') %></em>
+<!DOCTYPE html>
+<html>
+<head>
+ <meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
+ <style type="text/css">
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
+ .container { max-width: 600px; margin: 0 auto; padding: 20px; }
+ .header { font-size: 24px; margin-bottom: 10px; }
+ .subheading { color: #666; font-size: 14px; margin-bottom: 20px; }
+ ul { list-style-type: none; padding: 0; }
+ .date-heading { font-weight: bold; margin-top: 20px; color: #333; }
+ .post-link { display: block; margin-bottom: 5px; }
+ .post-link a { color: #0066cc; text-decoration: none; }
+ .post-link a:hover { text-decoration: underline; }
+ .meta { color: #666; font-size: 12px; }
+ .dash { color: #999; }
+ .bookmark-comment { color: #333; font-style: italic; }
+ blockquote {
+ margin: 10px 0;
+ padding: 10px;
+ background: #f5f5f5;
+ border-left: 3px solid #ddd;
+ }
+ .excerpt { color: #666; font-size: 14px; }
+ li { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
+ .dispatch-content { margin-top: 10px; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <h1 class="header">mind reader</h1>
+ <p class="subheading">Your weekly dispatch and bookmark digest.</p>
+
+ <ul>
+ <% @bookmarks.group_by { |bookmark| bookmark.created_at.to_date }.each do |date, items| %>
+ <li class="date-heading">
+ From <%= date.strftime('%B %e, %Y') %>:
+ </li>
+ <% items.each do |item| %>
+ <li>
+ <span class="post-link">
+ <% url = if item&.url&.present?
+ item.url
+ else
+ year = item&.created_at&.year || "2000"
+ "https://mndrdr.org/#{year}/#{item.slug}"
+ end %>
+ <strong><a href="<%= url %>" target="_blank"><%= item.title %></a></strong>
+ </span>
+
+ <% if item.dispatch? %>
+ <div class="dispatch-content">
+ <%= raw item.rendered_content %>
+ </div>
+ <% elsif item.content.present? %>
+ <div class="bookmark-comment">
+ <%= item.content %>
+ </div>
+ <% end %>
+
+ <% if item.summary? && (!@user&.hide_ai_summaries?) %>
+ <blockquote class="excerpt">
+ <strong>ML Summary:</strong> <%= item.summary %>
+ </blockquote>
+ <% end %>
+
+ <span class="meta">
+ Added <%= item.created_at.strftime('%l:%M%P') if item.created_at.present? %>
+ </span>
+ </li>
<% end %>
- <% if bookmark.created_at.present? %>
- <br><small>Added at <%= bookmark.created_at.strftime('%l:%M%P') %></small>
- <% end %>
- </li>
- <% end %>
- </ul>
-<% end %>
-<p>Have a great weekend,<br>
-Aidan.</p>
-<style type="text/css">
-<!--
- li { margin-bottom: 3px; }
- .dateline:first-of-type { margin-top: 0 }
- .dateline { margin-top: 5px; }
--->
-</style>
+ <% end %>
+ </ul>
+
+ <p style="margin-top: 30px; color: #666; font-size: 12px;">
+ Have a brilliant weekend,<br>Aidan.<br>
+ You're receiving this because you opted in to weekly digests.
+ You can change your preferences in your <a href="https://mndrdr.org/users/edit">account settings</a>.
+ </p>
+ </div>
+</body>
+</html>
<% content_for :title do %>Your mailing list membership<% end %>
<% content_for :form_content do %>
+<p>Below you can change your settings for receiving new dispatches as they are published. This is sent via a service called Buttondown, and you can manage your subscription below and via the settings at the bottom of emails sent by the service. Note, <%= link_to "members", subscriptions_path %> may also receive a digest version of mind reader which includes full-text dispatches and bookmarks via the opt-in on your <%= link_to "profile page", edit_user_registration_path %>.</p>
+
<% if @buttondown_status == 'unactivated' %>
<p>You are not currently subscribed to our mailing list.</p>
<%= button_to "Subscribe", subscribe_mailing_lists_path, method: :post, class: "button" %>
<% else %>
<p>Your current subscription status: <strong><%= @buttondown_status.titleize %></strong></p>
-
+
<% if @is_paid_user %>
<p>You are a paid subscriber. Thank you for your support!</p>
<% if @buttondown_status != 'gifted' %>
<%= button_to "Sync Subscription Status", sync_status_mailing_lists_path, method: :post, class: "button" %>
<% end %>
<% end %>
-
+
<%= button_to "Unsubscribe", unsubscribe_mailing_lists_path, method: :delete, class: "button danger", data: { confirm: "Are you sure you want to unsubscribe?" } %>
<% end %>
<% end %>
-<%= render template: 'layouts/user_page_template' %>
\ No newline at end of file
+<%= render template: 'layouts/user_page_template' %>
<li>One-time donation – or, as many times as you like, you like some of the work I’ve done here and want to let me know what I have done matters to you. You have my undying appreciation, and forever access to premium features.</li>
<li>Ongoing support – an annual subscription for as much as you can manage, you want me to commit more time to doing this work and are willing to become a patron of my work. You’ll have premium features forever, even if you end your subscription. This takes me one step closer to being able to craft daily change making theory.</li>
</ol>
-
- <% flash.each do |type, message| %>
- <div class="flash <%= type %>">
- <%= message %>
- </div>
- <% end %>
-
- <%= form_tag subscriptions_path, id: 'payment-form' do %>
- <div class="field">
- <%= label_tag :support_type, "Support Type" %>
- <%= select_tag :support_type,
- options_for_select([
- ['Non-financial support', 'non_financial'],
- ['One-time donation', 'one_time'],
- ['Ongoing support (annual)', 'ongoing']
- ], 'non_financial'),
- class: 'support-type-select'
- %>
- </div>
-
- <div id="payment-fields" style="display: none;">
- <div class="field support-amount">
- <%= label_tag :payment_amount, "Support amount" %>
- <%= text_field_tag :payment_amount, nil, class: 'currency-input', placeholder: "AU$0.00" %>
- </div>
-
- <div class="field">
- <label for="card-element">Credit or debit card</label>
- <div id="card-element"></div>
- <div id="card-errors" role="alert"></div>
- </div>
- </div>
-
- <div class="actions">
- <%= submit_tag "Pay now", class: "button primary" %>
- </div>
- <% end %>
+
+ <div class="form reg-form">
+ <% flash.each do |type, message| %>
+ <div class="flash <%= type %>">
+ <%= message %>
+ </div>
+ <% end %>
+
+ <%= form_tag subscriptions_path, id: 'payment-form' do %>
+ <div class="field">
+ <%= label_tag :support_type, "Support Type" %>
+ <%= select_tag :support_type,
+ options_for_select([
+ ['Non-financial support', 'non_financial'],
+ ['One-time donation', 'one_time'],
+ ['Ongoing support (annual)', 'ongoing']
+ ], 'non_financial'),
+ class: 'support-type-select'
+ %>
+ </div>
+
+ <div id="payment-fields" style="display: none;">
+ <div class="field support-amount">
+ <%= label_tag :payment_amount, "Support amount" %>
+ <%= text_field_tag :payment_amount, nil, class: 'currency-input', placeholder: "AU$0.00" %>
+ </div>
+
+ <div class="field">
+ <label for="card-element">Credit or debit card</label>
+ <div id="card-element"></div>
+ <div id="card-errors" role="alert"></div>
+ </div>
+ </div>
+
+ <div class="actions">
+ <%= submit_tag "Pay now", class: "button primary" %>
+ </div>
+ <% end %>
+ </div>
<% end %>
<% content_for :javascript do %>
}
};
var card = elements.create('card', {style: style, currency: 'aud'});
-
+
var supportTypeSelect = document.querySelector('.support-type-select');
var paymentFields = document.getElementById('payment-fields');
-
+
function togglePaymentFields() {
if (supportTypeSelect.value === 'one_time' || supportTypeSelect.value === 'ongoing') {
paymentFields.style.display = 'block';
// Initial toggle of payment fields
togglePaymentFields();
-
+
supportTypeSelect.addEventListener('change', togglePaymentFields);
card.addEventListener('change', function(event) {
var form = document.getElementById('payment-form');
form.addEventListener('submit', function(event) {
event.preventDefault();
-
+
if (supportTypeSelect.value === 'one_time' || supportTypeSelect.value === 'ongoing') {
stripe.createToken(card, {currency: 'aud'}).then(function(result) {
if (result.error) {
if (paymentAmountInput) {
// Set initial value
paymentAmountInput.value = 'AU$0.00';
-
+
paymentAmountInput.addEventListener('input', function(e) {
var cursorPosition = e.target.selectionStart;
var value = e.target.value.replace(/[^0-9.]/g, '');
-
+
var floatValue = parseFloat(value) || 0;
var formattedValue = 'AU$' + floatValue.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
-
+
var oldLength = e.target.value.length;
e.target.value = formattedValue;
var newLength = e.target.value.length;
-
+
// Adjust cursor position
var newPosition = cursorPosition + (newLength - oldLength);
newPosition = Math.max(3, Math.min(newPosition, formattedValue.length));
e.target.setSelectionRange(newPosition, newPosition);
});
-
+
paymentAmountInput.addEventListener('focus', function(e) {
if (e.target.value === 'AU$0.00') {
setTimeout(function() {
}, 0);
}
});
-
+
paymentAmountInput.addEventListener('blur', function(e) {
var value = parseFloat(e.target.value.replace(/[^0-9.]/g, '')) || 0;
e.target.value = 'AU$' + value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
</script>
<% end %>
-<%= render template: 'layouts/user_page_template' %>
\ No newline at end of file
+<%= render template: 'layouts/user_page_template' %>
--- /dev/null
+class AddDigestPreferencesToUsers < ActiveRecord::Migration[8.0]
+ def change
+ add_column :users, :digest_preference, :integer, default: 0, null: false
+ end
+end
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_01_12_054949) do
+ActiveRecord::Schema[8.0].define(version: 2025_01_13_020623) do
create_table "api_keys", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "key"
t.datetime "created_at", null: false
t.text "otp_backup_codes"
t.boolean "hide_ai_summaries", default: false
t.integer "consumed_timestep"
+ t.integer "digest_preference", default: 0, null: false
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true