From c6e1694139e0b5cc522cca7337d42ad6711250a4 Mon Sep 17 00:00:00 2001 From: Smit Date: Wed, 12 Nov 2025 02:49:48 +0530 Subject: [PATCH 1/9] send all missed posts at once --- app/controllers/posts_controller.rb | 63 ++++---- .../components/Audience/CustomersPage.tsx | 27 +++- app/javascript/data/customers.ts | 10 ++ app/policies/audience/purchase_policy.rb | 7 + app/presenters/customer_presenter.rb | 2 +- .../send_posts_for_purchase_service.rb | 37 +++++ app/sidekiq/send_missed_posts_job.rb | 12 ++ config/routes.rb | 1 + spec/controllers/posts_controller_spec.rb | 98 +++++++----- .../policies/audience/purchase_policy_spec.rb | 38 +++++ spec/requests/customers/customers_spec.rb | 69 ++++++++- .../send_posts_for_purchase_service_spec.rb | 141 ++++++++++++++++++ spec/sidekiq/send_missed_posts_job_spec.rb | 23 +++ 13 files changed, 453 insertions(+), 75 deletions(-) create mode 100644 app/services/send_posts_for_purchase_service.rb create mode 100644 app/sidekiq/send_missed_posts_job.rb create mode 100644 spec/services/send_posts_for_purchase_service_spec.rb create mode 100644 spec/sidekiq/send_missed_posts_job_spec.rb diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 92bd500406..5eda462c98 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -3,10 +3,12 @@ class PostsController < ApplicationController include CustomDomainConfig - before_action :authenticate_user!, only: %i[send_for_purchase] - after_action :verify_authorized, only: %i[send_for_purchase] + before_action :authenticate_user!, only: %i[send_for_purchase send_missed_posts] + after_action :verify_authorized, only: %i[send_for_purchase send_missed_posts] before_action :fetch_post, only: %i[send_for_purchase] - before_action :ensure_seller_is_eligible_to_send_emails, only: %i[send_for_purchase] + before_action :fetch_purchase, only: %i[send_for_purchase send_missed_posts] + before_action :ensure_seller_is_eligible_to_send_emails, only: %i[send_for_purchase send_missed_posts] + before_action :ensure_can_contact_for_purchase, only: %i[send_for_purchase send_missed_posts] before_action :set_user_and_custom_domain_config, only: %i[show] before_action :check_if_needs_redirect, only: %i[show] @@ -54,28 +56,19 @@ def redirect_from_purchase_id def send_for_purchase authorize @post - purchase = current_seller.sales.find_by_external_id!(params[:purchase_id]) - - # Limit the number of emails sent per post to avoid abuse. - Rails.cache.fetch("post_email:#{@post.id}:#{purchase.id}", expires_in: 8.hours) do - CreatorContactingCustomersEmailInfo.where(purchase:, installment: @post).destroy_all - - PostEmailApi.process( - post: @post, - recipients: [ - { - email: purchase.email, - purchase:, - url_redirect: purchase.url_redirect, - subscription: purchase.subscription, - }.compact_blank - ]) - true - end + SendPostsForPurchaseService.send_post(post: @post, purchase: @purchase) head :no_content end + def send_missed_posts + authorize [:audience, @purchase], :send_missed_posts? + + SendPostsForPurchaseService.send_missed_posts_for(purchase: @purchase) + + render json: { message: "Missed emails are queued for delivery" }, status: :ok + end + def increment_post_views fetch_post(false) @@ -99,14 +92,14 @@ def fetch_post(viewed_by_seller = true) end e404 if @post.blank? - if viewed_by_seller - e404 if @post.seller != current_seller + if @post.seller_id? + @seller = @post.seller + else + @seller = @post.link.seller end - if @post.seller_id? - e404 if @post.seller.suspended? - elsif @post.link_id? - e404 if @post.link.seller&.suspended? + if viewed_by_seller + e404 if @seller != current_seller end end @@ -120,10 +113,22 @@ def check_if_needs_redirect end end + def fetch_purchase + @purchase = current_seller.sales.find_by_external_id(params[:purchase_id]) + return e404_json if @purchase.blank? + + @seller = @purchase.seller + end + def ensure_seller_is_eligible_to_send_emails - seller = @post.seller || @post.link.seller - unless seller&.eligible_to_send_emails? + unless @seller&.eligible_to_send_emails? render json: { message: "You are not eligible to resend this email." }, status: :unauthorized end end + + def ensure_can_contact_for_purchase + unless @purchase.can_contact? + render json: { message: "This customer has opted out of receiving emails." }, status: :forbidden + end + end end diff --git a/app/javascript/components/Audience/CustomersPage.tsx b/app/javascript/components/Audience/CustomersPage.tsx index fa518a2b9d..517f4f049f 100644 --- a/app/javascript/components/Audience/CustomersPage.tsx +++ b/app/javascript/components/Audience/CustomersPage.tsx @@ -25,6 +25,7 @@ import { resendPing, refund, resendPost, + resendPosts, resendReceipt, updateLicense, updatePurchase, @@ -715,6 +716,21 @@ const CustomerDrawer = ({ setLoadingId(null); }; + const handleResendAll = async () => { + setLoadingId("all"); + try { + const response = await resendPosts(customer.id); + missedPosts?.forEach((post) => { + sentEmailIds.current.add(post.id); + }); + showAlert(response.message, "success"); + } catch (e) { + assertResponseError(e); + showAlert(e.message, "error"); + } + setLoadingId(null); + }; + const [productPurchases, setProductPurchases] = React.useState([]); const [selectedProductPurchaseId, setSelectedProductPurchaseId] = React.useState(null); const selectedProductPurchase = productPurchases.find(({ id }) => id === selectedProductPurchaseId); @@ -1199,7 +1215,7 @@ const CustomerDrawer = ({ {missedPosts?.length !== 0 ? (
-

Send missed posts

+

All missed emails

{missedPosts ? ( <> @@ -1218,7 +1234,7 @@ const CustomerDrawer = ({ disabled={!!loadingId || sentEmailIds.current.has(post.id)} onClick={() => void onSend(post.id, "post")} > - {sentEmailIds.current.has(post.id) ? "Sent" : loadingId === post.id ? "Sending...." : "Send"} + {sentEmailIds.current.has(post.id) ? "Sent" : loadingId === post.id ? "Resending..." : "Resend"}
))} @@ -1231,6 +1247,13 @@ const CustomerDrawer = ({ ) : null} + {missedPosts.length > 0 ? ( +
+ +
+ ) : null} ) : (
diff --git a/app/javascript/data/customers.ts b/app/javascript/data/customers.ts index ee99189179..9f68ff00c1 100644 --- a/app/javascript/data/customers.ts +++ b/app/javascript/data/customers.ts @@ -241,6 +241,16 @@ export const resendPost = async (purchaseId: string, postId: string) => { if (!response.ok) throw new ResponseError(cast<{ message: string }>(await response.json()).message); }; +export const resendPosts = async (purchaseId: string) => { + const response = await request({ + method: "POST", + accept: "json", + url: Routes.send_missed_posts_path(purchaseId), + }); + if (!response.ok) throw new ResponseError(cast<{ message: string }>(await response.json()).message); + return cast<{ message: string }>(await response.json()); +}; + export const updatePurchase = ( purchaseId: string, update: Partial<{ email: string; giftee_email: string; quantity: number } & Address>, diff --git a/app/policies/audience/purchase_policy.rb b/app/policies/audience/purchase_policy.rb index 3d8512a76e..c9df6b3a8d 100644 --- a/app/policies/audience/purchase_policy.rb +++ b/app/policies/audience/purchase_policy.rb @@ -49,4 +49,11 @@ def undo_revoke_access? update? && record.is_access_revoked end + + def send_missed_posts? + return false unless record.seller == seller + + update? || + user.role_marketing_for?(seller) + end end diff --git a/app/presenters/customer_presenter.rb b/app/presenters/customer_presenter.rb index c05893d50d..38159fa50b 100644 --- a/app/presenters/customer_presenter.rb +++ b/app/presenters/customer_presenter.rb @@ -8,7 +8,7 @@ def initialize(purchase:) end def missed_posts - posts = Installment.missed_for_purchase(purchase).order(published_at: :desc) + posts = SendPostsForPurchaseService.find_missed_posts_for(purchase:).order(published_at: :desc) posts.map do |post| { id: post.external_id, diff --git a/app/services/send_posts_for_purchase_service.rb b/app/services/send_posts_for_purchase_service.rb new file mode 100644 index 0000000000..df41880fe6 --- /dev/null +++ b/app/services/send_posts_for_purchase_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class SendPostsForPurchaseService + class << self + def find_missed_posts_for(purchase:) + Installment.missed_for_purchase(purchase) + end + + def send_post(post:, purchase:) + # Limit the number of emails sent per post to avoid abuse. + Rails.cache.fetch("post_email:#{post.id}:#{purchase.id}", expires_in: 8.hours) do + CreatorContactingCustomersEmailInfo.where(purchase:, installment: post).destroy_all + + PostEmailApi.process( + post:, + recipients: [{ + email: purchase.email, + purchase:, + url_redirect: purchase.url_redirect, + subscription: purchase.subscription, + }.compact_blank] + ) + true + end + end + + def send_missed_posts_for(purchase:) + SendMissedPostsJob.perform_async(purchase.id) + end + + def deliver_missed_posts_for(purchase:) + find_missed_posts_for(purchase:).find_each do |post| + send_post(post:, purchase:) + end + end + end +end diff --git a/app/sidekiq/send_missed_posts_job.rb b/app/sidekiq/send_missed_posts_job.rb new file mode 100644 index 0000000000..37e03e71db --- /dev/null +++ b/app/sidekiq/send_missed_posts_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class SendMissedPostsJob + include Sidekiq::Job + sidekiq_options retry: 5, queue: :default, lock: :until_executed + + def perform(purchase_id) + purchase = Purchase.find(purchase_id) + + SendPostsForPurchaseService.deliver_missed_posts_for(purchase:) + end +end diff --git a/config/routes.rb b/config/routes.rb index 365083f8b7..0a632b6978 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -565,6 +565,7 @@ def product_info_and_purchase_routes(named_routes: true) get "/customers/charges/:purchase_id", to: "customers#customer_charges", as: :customer_charges get "/customers/customer_emails/:purchase_id", to: "customers#customer_emails", as: :customer_emails get "/customers/missed_posts/:purchase_id", to: "customers#missed_posts", as: :missed_posts + post "/customers/:purchase_id/send_missed_posts", to: "posts#send_missed_posts", as: :send_missed_posts get "/customers/product_purchases/:purchase_id", to: "customers#product_purchases", as: :product_purchases # imported customers get "/imported_customers", to: "imported_customers#index", as: :imported_customers diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 6cebc48d84..e55c044d95 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -31,7 +31,7 @@ end end - describe "GET send_for_purchase" do + describe "POST send_for_purchase" do before do link = create(:product, user: seller) @post = create(:installment, link:) @@ -44,7 +44,7 @@ allow_any_instance_of(User).to receive(:sales_cents_total).and_return(Installment::MINIMUM_SALES_CENTS_VALUE) end - it_behaves_like "authorize called for action", :get, :send_for_purchase do + it_behaves_like "authorize called for action", :post, :send_for_purchase do let(:record) { Installment } let(:request_params) { { id: @post.external_id, purchase_id: @purchase.external_id } } end @@ -52,51 +52,79 @@ it "returns an error if seller is not eligible to send emails" do allow_any_instance_of(User).to receive(:sales_cents_total).and_return(Installment::MINIMUM_SALES_CENTS_VALUE - 1) - @purchase.create_url_redirect! - expect(PostSendgridApi).to_not receive(:process) - get :send_for_purchase, params: { id: @post.external_id, purchase_id: @purchase.external_id } + post :send_for_purchase, params: { id: @post.external_id, purchase_id: @purchase.external_id } expect(response).to have_http_status(:unauthorized) expect(response.parsed_body).to eq("message" => "You are not eligible to resend this email.") end it "returns 404 if no purchase" do - expect(PostSendgridApi).to_not receive(:process) - expect do - get :send_for_purchase, params: { id: @post.external_id, purchase_id: "hello" } - end.to raise_error(ActiveRecord::RecordNotFound) + post :send_for_purchase, params: { id: @post.external_id, purchase_id: "hello" } + + expect(response).to have_http_status(:not_found) + expect(response.parsed_body).to eq("success" => false, "error" => "Not found") end - it "returns success and redelivers the installment" do - @purchase.create_url_redirect! - expect(PostSendgridApi).to receive(:process).with( - post: @post, - recipients: [{ - email: @purchase.email, - purchase: @purchase, - url_redirect: @purchase.url_redirect, - }] - ) - get :send_for_purchase, params: { id: @post.external_id, purchase_id: @purchase.external_id } - expect(response).to be_successful - expect(response).to have_http_status(:no_content) + it "returns success" do + allow(SendPostsForPurchaseService).to receive(:send_post) - # when the purchase part of a subscription - membership_purchase = create(:membership_purchase, link: create(:membership_product, user: @post.seller)) - membership_purchase.create_url_redirect! - expect(PostSendgridApi).to receive(:process).with( - post: @post, - recipients: [{ - email: membership_purchase.email, - purchase: membership_purchase, - url_redirect: membership_purchase.url_redirect, - subscription: membership_purchase.subscription, - }] - ) - get :send_for_purchase, params: { id: @post.external_id, purchase_id: membership_purchase.external_id } + post :send_for_purchase, params: { id: @post.external_id, purchase_id: @purchase.external_id } + expect(SendPostsForPurchaseService).to have_received(:send_post).with(post: @post, purchase: @purchase) expect(response).to be_successful expect(response).to have_http_status(:no_content) end end + + describe "POST send_missed_posts" do + before do + link = create(:product, user: seller) + @purchase = create(:purchase, seller:, link:, created_at: Time.current) + end + + before do + create(:payment_completed, user: seller) + allow_any_instance_of(User).to receive(:sales_cents_total).and_return(Installment::MINIMUM_SALES_CENTS_VALUE) + end + + it_behaves_like "authorize called for action", :post, :send_missed_posts do + let(:record) { @purchase } + let(:policy_klass) { Audience::PurchasePolicy } + let(:request_params) { { purchase_id: @purchase.external_id } } + end + + it "returns success" do + post :send_missed_posts, params: { purchase_id: @purchase.external_id } + + expect(SendMissedPostsJob).to have_enqueued_sidekiq_job(@purchase.id).on("default") + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq("message" => "Missed emails are queued for delivery") + end + + it "returns an error if seller is not eligible to send emails" do + allow_any_instance_of(User).to receive(:sales_cents_total).and_return(Installment::MINIMUM_SALES_CENTS_VALUE - 1) + + post :send_missed_posts, params: { purchase_id: @purchase.external_id } + expect(response).to have_http_status(:unauthorized) + expect(response.parsed_body).to eq("message" => "You are not eligible to resend this email.") + expect(SendMissedPostsJob.jobs.size).to eq(0) + end + + it "returns an error if user has opted out of receiving emails" do + @purchase.update!(can_contact: false) + + post :send_missed_posts, params: { purchase_id: @purchase.external_id } + expect(response).to have_http_status(:forbidden) + expect(response.parsed_body).to eq("message" => "This customer has opted out of receiving emails.") + expect(SendMissedPostsJob.jobs.size).to eq(0) + end + + it "returns 404 if no purchase" do + post :send_missed_posts, params: { purchase_id: "hello" } + + expect(response).to have_http_status(:not_found) + expect(response.parsed_body).to eq("success" => false, "error" => "Not found") + expect(SendMissedPostsJob.jobs.size).to eq(0) + end + end end context "within consumer area" do diff --git a/spec/policies/audience/purchase_policy_spec.rb b/spec/policies/audience/purchase_policy_spec.rb index e1cdd80d5d..256b281b5c 100644 --- a/spec/policies/audience/purchase_policy_spec.rb +++ b/spec/policies/audience/purchase_policy_spec.rb @@ -175,4 +175,42 @@ end end end + + permissions :send_missed_posts? do + let(:link) { create(:product, user: seller) } + let(:purchase) { create(:purchase, seller:, link:) } + let(:other_seller) { create(:user) } + let(:other_link) { create(:product, user: other_seller) } + let(:other_purchase) { create(:purchase, seller: other_seller, link: other_link) } + + it "grants access to owner" do + seller_context = SellerContext.new(user: seller, seller:) + expect(subject).to permit(seller_context, purchase) + end + + it "denies access to accountant" do + seller_context = SellerContext.new(user: accountant_for_seller, seller:) + expect(subject).not_to permit(seller_context, purchase) + end + + it "grants access to admin" do + seller_context = SellerContext.new(user: admin_for_seller, seller:) + expect(subject).to permit(seller_context, purchase) + end + + it "grants access to marketing" do + seller_context = SellerContext.new(user: marketing_for_seller, seller:) + expect(subject).to permit(seller_context, purchase) + end + + it "grants access to support" do + seller_context = SellerContext.new(user: support_for_seller, seller:) + expect(subject).to permit(seller_context, purchase) + end + + it "denies access if purchase belongs to different seller" do + seller_context = SellerContext.new(user: seller, seller:) + expect(subject).not_to permit(seller_context, other_purchase) + end + end end diff --git a/spec/requests/customers/customers_spec.rb b/spec/requests/customers/customers_spec.rb index 1c29b879e4..1be32dbf4c 100644 --- a/spec/requests/customers/customers_spec.rb +++ b/spec/requests/customers/customers_spec.rb @@ -474,7 +474,7 @@ def fill_in_date(field_label, date) visit customers_path find(:table_row, { "Name" => "Customer 1" }).click within_modal "Product 1" do - within_section "Send missed posts", section_element: :section do + within_section "All missed emails", section_element: :section do 10.times do |i| expect(page).to have_section("Post #{i}") end @@ -484,8 +484,8 @@ def fill_in_date(field_label, date) within_section "Post 10" do expect(page).to have_link("Post 10", href: post.full_url) expect(page).to have_text("Originally sent on #{post.published_at.strftime("%b %-d")}") - click_on "Send" - expect(page).to have_button("Sending...", disabled: true) + click_on "Resend" + expect(page).to have_button("Resending...", disabled: true) end end end @@ -496,7 +496,7 @@ def fill_in_date(field_label, date) visit customers_path find(:table_row, { "Name" => "Customer 1" }).click within_modal "Product 1" do - within_section "Send missed posts", section_element: :section do + within_section "All missed emails", section_element: :section do expect(page).to_not have_button("Show more") expect(page).to_not have_section("Post 10") end @@ -508,25 +508,78 @@ def fill_in_date(field_label, date) end end end - expect(page).to have_alert(text: "Sent") within_section("Post 10") { expect(page).to have_button("Sent", disabled: true) } + + visit customers_path + find(:table_row, { "Name" => "Customer 1" }).click + within_modal "Product 1" do + within_section "All missed emails", section_element: :section do + expect(page).to have_button("Resend all") + click_on "Resend all" + expect(page).to have_button("Resending all...", disabled: true) + expect(page).to have_button("Sent", disabled: true, count: 10) + end + end + expect(page).to have_alert(text: "Missed emails are queued for delivery") end it "does not allow re-sending an email if the seller is not eligible to send emails" do visit customers_path find(:table_row, { "Name" => "Customer 1" }).click within_modal "Product 1" do - within_section "Send missed posts", section_element: :section do + within_section "All missed emails", section_element: :section do click_on "Show more" within_section "Post 10" do - click_on "Send" - expect(page).to have_button("Sending...", disabled: true) + click_on "Resend" + expect(page).to have_button("Resending...", disabled: true) end end end expect(page).to have_alert(text: "You are not eligible to resend this email.") expect(EmailInfo.last.installment).to be_nil + + within_modal "Product 1" do + within_section "All missed emails", section_element: :section do + expect(page).to have_button("Resend all") + click_on "Resend all" + expect(page).to have_button("Resending all...", disabled: true) + end + end + expect(page).to have_alert(text: "You are not eligible to resend this email.") + expect(EmailInfo.last.installment).to be_nil + end + + it "does not allow re-sending customer has opted out of receiving emails" do + allow_any_instance_of(User).to receive(:sales_cents_total).and_return(Installment::MINIMUM_SALES_CENTS_VALUE) + create(:payment_completed, user: seller) + purchase1.update!(can_contact: false) + + visit customers_path + find(:table_row, { "Name" => "Customer 1" }).click + within_modal "Product 1" do + within_section "All missed emails", section_element: :section do + click_on "Show more" + within_section "Post 10" do + click_on "Resend" + expect(page).to have_button("Resending...", disabled: true) + end + end + end + expect(page).to have_alert(text: "This customer has opted out of receiving emails.") + expect(EmailInfo.last.installment).to be_nil + + visit customers_path + find(:table_row, { "Name" => "Customer 1" }).click + within_modal "Product 1" do + within_section "All missed emails", section_element: :section do + expect(page).to have_button("Resend all") + click_on "Resend all" + expect(page).to have_button("Resending all...", disabled: true) + end + end + expect(page).to have_alert(text: "This customer has opted out of receiving emails.") + expect(EmailInfo.last.installment).to be_nil end end diff --git a/spec/services/send_posts_for_purchase_service_spec.rb b/spec/services/send_posts_for_purchase_service_spec.rb new file mode 100644 index 0000000000..32f2dd4291 --- /dev/null +++ b/spec/services/send_posts_for_purchase_service_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe SendPostsForPurchaseService do + let(:seller) { create(:named_seller) } + let(:product) { create(:product, user: seller) } + let(:purchase) { create(:purchase, seller:, link: product) } + + describe ".find_missed_posts_for" do + let!(:sent_post) { create(:installment, link: product, published_at: 2.days.ago) } + let!(:missed_post1) { create(:installment, link: product, published_at: 1.day.ago) } + let!(:missed_post2) { create(:installment, link: product, published_at: Time.current) } + + before do + create(:creator_contacting_customers_email_info, installment: sent_post, purchase:) + end + + it "returns only posts that haven't been sent" do + result = described_class.find_missed_posts_for(purchase:) + + expect(result).to include(missed_post1, missed_post2) + expect(result).not_to include(sent_post) + end + end + + describe ".send_post" do + let(:post) { create(:installment, link: product) } + + before do + PostSendgridApi.mails.clear + end + + it "sends email and creates email record" do + purchase.create_url_redirect! + Rails.cache.delete("post_email:#{post.id}:#{purchase.id}") + + expect(PostEmailApi).to receive(:process).with( + post:, + recipients: [{ + email: purchase.email, + purchase:, + url_redirect: purchase.url_redirect, + subscription: purchase.subscription, + }.compact_blank] + ).and_call_original + + expect do + result = described_class.send_post(post:, purchase:) + expect(result).to be true + end.to change { CreatorContactingCustomersEmailInfo.count }.by(1) + + email_info = CreatorContactingCustomersEmailInfo.last + expect(email_info.attributes).to include( + "type" => "CreatorContactingCustomersEmailInfo", + "installment_id" => post.id, + "purchase_id" => purchase.id, + "state" => "sent", + "email_name" => "purchase_installment" + ) + expect(email_info.sent_at).to be_within(1.second).of(Time.current) + + expect(PostSendgridApi.mails.size).to eq(1) + expect(PostSendgridApi.mails.keys).to include(purchase.email) + end + + it "includes subscription for membership purchases" do + membership_purchase = create(:membership_purchase, link: create(:membership_product, user: seller)) + membership_purchase.create_url_redirect! + Rails.cache.delete("post_email:#{post.id}:#{membership_purchase.id}") + + expect(PostEmailApi).to receive(:process).with( + post:, + recipients: [{ + email: membership_purchase.email, + purchase: membership_purchase, + url_redirect: membership_purchase.url_redirect, + subscription: membership_purchase.subscription, + }.compact_blank] + ).and_call_original + + expect do + described_class.send_post(post:, purchase: membership_purchase) + end.to change { CreatorContactingCustomersEmailInfo.count }.by(1) + + email_info = CreatorContactingCustomersEmailInfo.last + expect(email_info.purchase_id).to eq(membership_purchase.id) + expect(PostSendgridApi.mails.size).to eq(1) + expect(PostSendgridApi.mails.keys).to include(membership_purchase.email) + end + end + + describe ".send_missed_posts_for" do + it "enqueues SendMissedPostsJob with purchase ID" do + described_class.send_missed_posts_for(purchase:) + + expect(SendMissedPostsJob).to have_enqueued_sidekiq_job(purchase.id).on("default") + end + end + + describe ".deliver_missed_posts_for" do + let!(:sent_post) { create(:installment, link: product, seller:, published_at: 2.days.ago) } + let!(:missed_post1) { create(:installment, link: product, seller:, published_at: 1.day.ago) } + let!(:missed_post2) { create(:installment, link: product, seller:, published_at: Time.current) } + + before do + purchase.create_url_redirect! + PostSendgridApi.mails.clear + create(:creator_contacting_customers_email_info, installment: sent_post, purchase:) + end + + it "sends emails for missed posts" do + initial_count = CreatorContactingCustomersEmailInfo.count + [missed_post1, missed_post2].each do |p| + Rails.cache.delete("post_email:#{p.id}:#{purchase.id}") + end + + expect do + described_class.deliver_missed_posts_for(purchase:) + end.to change { CreatorContactingCustomersEmailInfo.count }.by(2) + + email_infos = CreatorContactingCustomersEmailInfo.where(purchase:).where("id > ?", initial_count).order(:id).last(2) + expect(email_infos.map(&:installment_id)).to contain_exactly(missed_post1.id, missed_post2.id) + + email_infos.each do |email_info| + expect(email_info.attributes).to include( + "type" => "CreatorContactingCustomersEmailInfo", + "purchase_id" => purchase.id, + "state" => "sent", + "email_name" => "purchase_installment" + ) + expect(email_info.sent_at).to be_within(5.seconds).of(Time.current) + end + + expect(PostSendgridApi.mails.size).to eq(1) + expect(PostSendgridApi.mails.keys).to include(purchase.email) + + expect(CreatorContactingCustomersEmailInfo.where(installment: sent_post, purchase:).count).to eq(1) + end + end +end diff --git a/spec/sidekiq/send_missed_posts_job_spec.rb b/spec/sidekiq/send_missed_posts_job_spec.rb new file mode 100644 index 0000000000..5fda9131b4 --- /dev/null +++ b/spec/sidekiq/send_missed_posts_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe SendMissedPostsJob do + describe "#perform" do + let(:seller) { create(:named_seller) } + let(:product) { create(:product, user: seller) } + let(:purchase) { create(:purchase, seller:, link: product) } + + it "finds purchase by ID and calls service" do + expect(SendPostsForPurchaseService).to receive(:deliver_missed_posts_for).with(purchase:) + + described_class.new.perform(purchase.id) + end + + it "raises error when purchase is not found" do + expect do + described_class.new.perform(999999) + end.to raise_error(ActiveRecord::RecordNotFound) + end + end +end From c5d25a62855ccacf76381abc9ef82ea3e12e792e Mon Sep 17 00:00:00 2001 From: Smit Date: Thu, 27 Nov 2025 05:25:36 +0530 Subject: [PATCH 2/9] send all missed posts by workflows --- app/controllers/customers_controller.rb | 2 +- app/controllers/posts_controller.rb | 2 +- app/controllers/workflows_controller.rb | 15 ++- .../components/Admin/EmptyState.tsx | 7 +- .../components/Audience/CustomersPage.tsx | 106 +++++++++++++----- app/javascript/data/customers.ts | 38 ++++++- app/presenters/customer_presenter.rb | 7 +- app/presenters/workflow_presenter.rb | 7 ++ app/presenters/workflows_presenter.rb | 15 ++- .../send_posts_for_purchase_service.rb | 21 +++- app/sidekiq/send_missed_posts_job.rb | 4 +- spec/controllers/posts_controller_spec.rb | 2 +- spec/controllers/workflows_controller_spec.rb | 39 +++++++ spec/presenters/workflow_presenter_spec.rb | 15 +++ spec/presenters/workflows_presenter_spec.rb | 30 +++-- spec/requests/customers/customers_spec.rb | 20 ++-- .../send_posts_for_purchase_service_spec.rb | 2 +- spec/sidekiq/send_missed_posts_job_spec.rb | 2 +- 18 files changed, 266 insertions(+), 68 deletions(-) diff --git a/app/controllers/customers_controller.rb b/app/controllers/customers_controller.rb index c9e176b883..e35feede93 100644 --- a/app/controllers/customers_controller.rb +++ b/app/controllers/customers_controller.rb @@ -110,7 +110,7 @@ def customer_emails def missed_posts purchase = Purchase.where(email: params[:purchase_email].to_s).find_by_external_id!(params[:purchase_id]) - render json: CustomerPresenter.new(purchase:).missed_posts + render json: CustomerPresenter.new(purchase:).missed_posts(workflow_id: params[:workflow_id]) end def product_purchases diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 5eda462c98..26f4f995c2 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -64,7 +64,7 @@ def send_for_purchase def send_missed_posts authorize [:audience, @purchase], :send_missed_posts? - SendPostsForPurchaseService.send_missed_posts_for(purchase: @purchase) + SendPostsForPurchaseService.send_missed_posts_for(purchase: @purchase, workflow_id: params[:workflow_id]) render json: { message: "Missed emails are queued for delivery" }, status: :ok end diff --git a/app/controllers/workflows_controller.rb b/app/controllers/workflows_controller.rb index e2bad46325..bc992f2730 100644 --- a/app/controllers/workflows_controller.rb +++ b/app/controllers/workflows_controller.rb @@ -14,10 +14,19 @@ class WorkflowsController < Sellers::BaseController def index authorize Workflow - create_user_event("workflows_view") - workflows_presenter = WorkflowsPresenter.new(seller: current_seller) - render inertia: "Workflows/Index", props: workflows_presenter.workflows_props + + respond_to do |format| + format.html do + create_user_event("workflows_view") + render inertia: "Workflows/Index", props: workflows_presenter.workflows_props + end + format.json do + return render json: { error: "Bad request" }, status: :bad_request unless params[:purchase_id].present? + + render json: workflows_presenter.workflow_options_by_purchase_props(purchase: current_seller.sales.find_by_external_id(params[:purchase_id])) + end + end end def new diff --git a/app/javascript/components/Admin/EmptyState.tsx b/app/javascript/components/Admin/EmptyState.tsx index 3dd4906314..ee7b153960 100644 --- a/app/javascript/components/Admin/EmptyState.tsx +++ b/app/javascript/components/Admin/EmptyState.tsx @@ -1,11 +1,14 @@ import React from "react"; +import { classNames } from "$app/utils/classNames"; + type Props = { + className?: string; message: string; }; -const EmptyState = ({ message }: Props) => ( -
+const EmptyState = ({ className, message }: Props) => ( +

{message}

); diff --git a/app/javascript/components/Audience/CustomersPage.tsx b/app/javascript/components/Audience/CustomersPage.tsx index 517f4f049f..0fe9d46e13 100644 --- a/app/javascript/components/Audience/CustomersPage.tsx +++ b/app/javascript/components/Audience/CustomersPage.tsx @@ -11,6 +11,7 @@ import { Discount, License, MissedPost, + Workflow, Query, Charge, SortKey, @@ -19,6 +20,7 @@ import { changeCanContact, getCustomerEmails, getMissedPosts, + getWorkflowsForPurchase, getPagedCustomers, getProductPurchases, markShipped, @@ -58,6 +60,7 @@ import { asyncVoid } from "$app/utils/promise"; import { RecurrenceId, recurrenceLabels } from "$app/utils/recurringPricing"; import { AbortError, assertResponseError } from "$app/utils/request"; +import EmptyState from "$app/components/Admin/EmptyState"; import { Button, NavigationButton } from "$app/components/Button"; import { useCurrentSeller } from "$app/components/CurrentSeller"; import { DateInput } from "$app/components/DateInput"; @@ -688,21 +691,58 @@ const CustomerDrawer = ({ const [loadingId, setLoadingId] = React.useState(null); const [missedPosts, setMissedPosts] = React.useState(null); + const [workflows, setWorkflows] = React.useState([]); + const [selectedWorkflowId, setSelectedWorkflowId] = React.useState(undefined); const [shownMissedPosts, setShownMissedPosts] = React.useState(PAGE_SIZE); const [emails, setEmails] = React.useState(null); const [shownEmails, setShownEmails] = React.useState(PAGE_SIZE); const sentEmailIds = React.useRef>(new Set()); + const initialLoadCompleteRef = React.useRef(false); + useRunOnce(() => { - getMissedPosts(customer.id, customer.email).then(setMissedPosts, (e: unknown) => { - assertResponseError(e); - showAlert(e.message, "error"); - }); getCustomerEmails(customer.id).then(setEmails, (e: unknown) => { assertResponseError(e); showAlert(e.message, "error"); }); + + getWorkflowsForPurchase(customer.id).then( + (data) => { + setWorkflows(data); + }, + (e: unknown) => { + assertResponseError(e); + showAlert(e.message, "error"); + }, + ); + + getMissedPosts(customer.id, customer.email).then( + (data) => { + setMissedPosts(data); + initialLoadCompleteRef.current = true; + }, + (e: unknown) => { + assertResponseError(e); + showAlert(e.message, "error"); + }, + ); }); + React.useEffect(() => { + if (initialLoadCompleteRef.current) { + setMissedPosts(null); + getMissedPosts(customer.id, customer.email, selectedWorkflowId).then( + (data) => { + setMissedPosts(data); + }, + (e: unknown) => { + assertResponseError(e); + showAlert(e.message, "error"); + setMissedPosts([]); + }, + ); + } + }, [selectedWorkflowId, customer.id, customer.email]); + const onSend = async (id: string, type: "receipt" | "post") => { setLoadingId(id); try { @@ -719,7 +759,7 @@ const CustomerDrawer = ({ const handleResendAll = async () => { setLoadingId("all"); try { - const response = await resendPosts(customer.id); + const response = await resendPosts(customer.id, selectedWorkflowId); missedPosts?.forEach((post) => { sentEmailIds.current.add(post.id); }); @@ -1212,12 +1252,26 @@ const CustomerDrawer = ({ {commission ? ( onChange({ commission })} /> ) : null} - {missedPosts?.length !== 0 ? ( -
-
-

All missed emails

-
- {missedPosts ? ( +
+
+