Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/channels/application_cable/channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
120 changes: 120 additions & 0 deletions app/channels/soup_campaign_channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Realtime collaboration channel for Soup campaign editing.
# Handles Yjs CRDT sync messages and presence (who's editing, cursor positions).
# Each connected client sends a unique tab_id so we can deduplicate same-user multi-tab sessions.
class SoupCampaignChannel < ApplicationCable::Channel
PRESENCE_KEY_PREFIX = "soup_campaign_presence:"
PRESENCE_TTL = 30 # seconds

def subscribed
campaign = SoupCampaign.find_by(id: params[:campaign_id])
return reject unless campaign && current_user&.admin?

@campaign = campaign
@tab_id = params[:tab_id]

stream_from stream_name
broadcast_presence_update
end

def unsubscribed
broadcast_presence_leave
clear_my_presence
end

# Client sends a Yjs awareness update (cursor/selection) — relay to all peers, no persistence needed
def awareness(data)
ActionCable.server.broadcast(stream_name, {
type: "awareness",
update: data["update"],
tab_id: @tab_id
})
end

# Client sends an incremental Yjs update — relay to peers only (fast path)
def sync(data)
# Relay to all other subscribers immediately
ActionCable.server.broadcast(stream_name, {
type: "sync",
update: data["update"],
tab_id: @tab_id
})
end

# Client sends a debounced autosave with FULL Yjs state and field values
def autosave(data)
update_bytes = Base64.strict_decode64(data["update"])

@campaign.with_lock do
# Overwrite the entire state vector since the client sent the full document
@campaign.update_columns(yjs_state: update_bytes)
flush_fields(data["fields"]) if data["fields"]
end
end

# Client sends presence update (cursor field, selection, etc.)
def presence(data)
store_my_presence(data)
ActionCable.server.broadcast(stream_name, {
type: "presence",
user: presence_user_payload,
tab_id: @tab_id,
data: data
})
end

private

def stream_name
"soup_campaign:#{@campaign.id}"
end

def flush_fields(fields)
permitted = %w[name body footer unsubscribe_label image_url]
updates = fields.slice(*permitted)
@campaign.update_columns(updates) if updates.any?
end

def presence_user_payload
{
id: current_user.id,
display_name: current_user.display_name,
avatar: current_user.avatar,
tab_id: @tab_id,
color: user_color
}
end

def user_color
# Deterministic hue from user id so the same person always gets the same color
hue = (current_user.id * 47) % 360
"hsl(#{hue}, 70%, 55%)"
end

def broadcast_presence_update
store_my_presence({})
ActionCable.server.broadcast(stream_name, {
type: "presence_join",
user: presence_user_payload
})
end

def broadcast_presence_leave
ActionCable.server.broadcast(stream_name, {
type: "presence_leave",
tab_id: @tab_id
})
end

def presence_redis_key
"#{PRESENCE_KEY_PREFIX}#{@campaign.id}:#{current_user.id}:#{@tab_id}"
end

def store_my_presence(data)
# Use Rails cache (backed by Redis in production) with TTL for auto-cleanup
Rails.cache.write(presence_redis_key, { user: presence_user_payload, data: data }, expires_in: PRESENCE_TTL)
end

def clear_my_presence
Rails.cache.delete(presence_redis_key)
end
end
234 changes: 234 additions & 0 deletions app/controllers/admin/soup_campaigns_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
class Admin::SoupCampaignsController < Admin::ApplicationController
before_action :require_admin! # Soup campaigns are admin-only

skip_after_action :verify_authorized, only: %i[index] # index uses policy_scope; create uses skip_authorization since it redirects immediately
skip_after_action :verify_policy_scoped, only: %i[show new create update destroy send_campaign test_send cancel toggle_unsubscribe] # non-index actions use authorize

def index
campaigns = policy_scope(SoupCampaign).recent.includes(:created_by)

render inertia: "admin/soup_campaigns/index", props: {
campaigns: campaigns.map { |c| serialize_campaign(c) }
}
end

def show
campaign = SoupCampaign.find(params[:id])
authorize campaign

if campaign.draft?
# Show projected audience (who would receive the campaign) before sending
projected_scope = User.verified.kept.where.not(slack_id: nil).order(:display_name)
pagy, projected_users = pagy(projected_scope, limit: 50, page: params[:rp], page_param: :rp)
recipients = projected_users.map do |u|
{ id: u.id, slack_id: u.slack_id, display_name: u.display_name, status: "projected",
sent_at: nil, error_message: nil }
end
recipients_pagy = pagy_props(pagy)
else
pagy, recipient_records = pagy(
campaign.soup_campaign_recipients.order(created_at: :asc),
limit: 50, page: params[:rp], page_param: :rp
)
recipients = recipient_records.map { |r| serialize_recipient(r) }
recipients_pagy = pagy_props(pagy)
end

render inertia: "admin/soup_campaigns/show", props: {
campaign: serialize_campaign(campaign),
recipients:,
recipients_pagy:,
stats: campaign.recipient_stats,
progress: campaign.progress_percent
}
end

def new
# Auto-create a blank draft and open the collaborative editor directly
campaign = SoupCampaign.new(
name: "Untitled campaign",
body: "",
footer: "",
unsubscribe_label: SoupCampaign::DEFAULT_UNSUBSCRIBE_LABEL,
created_by: current_user
)
authorize campaign

campaign.save!
redirect_to edit_admin_soup_campaign_path(campaign)
end

def create
# Unused — new action auto-creates and redirects to edit
skip_authorization
head :not_found
end

def update
campaign = SoupCampaign.find(params[:id])
authorize campaign

if campaign.update(campaign_params)
redirect_to admin_soup_campaign_path(campaign), notice: "Campaign updated."
else
render inertia: "admin/soup_campaigns/edit", props: {
campaign: serialize_campaign(campaign),
yjs_state: campaign.yjs_state.present? ? Base64.strict_encode64(campaign.yjs_state) : nil,
errors: campaign.errors.as_json
}
end
end

def edit
campaign = SoupCampaign.find(params[:id])
authorize campaign, :update?

render inertia: "admin/soup_campaigns/edit", props: {
campaign: serialize_campaign(campaign),
yjs_state: campaign.yjs_state.present? ? Base64.strict_encode64(campaign.yjs_state) : nil,
current_user_presence: {
id: current_user.id,
display_name: current_user.display_name,
avatar: current_user.avatar
}
}
end

def destroy
campaign = SoupCampaign.find(params[:id])
authorize campaign

campaign.destroy!
redirect_to admin_soup_campaigns_path, notice: "Campaign deleted."
end

def send_campaign
campaign = SoupCampaign.find(params[:id])
authorize campaign

unless campaign.draft?
redirect_to admin_soup_campaign_path(campaign), alert: "Campaign has already been sent or is currently sending."
return
end

SendSoupCampaignJob.perform_later(campaign.id)
redirect_to admin_soup_campaign_path(campaign), notice: "Campaign is now sending!"
end

def test_send
campaign = SoupCampaign.find(params[:id])
authorize campaign

slack_id = params[:slack_id].to_s.strip
return render json: { error: "slack_id is required" }, status: :unprocessable_entity if slack_id.blank?

unsubscribe_url = soup_campaign_unsubscribe_url(
token: "test-token",
host: ENV.fetch("APP_HOST", "fallout.hackclub.com")
)

client = Slack::Web::Client.new(token: ENV.fetch("SLACK_BOT_TOKEN", nil))
client.chat_postMessage(
channel: slack_id,
text: "[TEST] #{campaign.body}",
username: "Soup",
icon_url: "https://avatars.slack-edge.com/2026-03-03/10620134255189_994e10cd91f0fc88ad9c_512.jpg",
blocks: build_test_blocks(campaign, unsubscribe_url).to_json
)

render json: { ok: true }
rescue Slack::Web::Api::Errors::SlackError => e
render json: { error: e.message }, status: :unprocessable_entity
end

def cancel
campaign = SoupCampaign.find(params[:id])
authorize campaign

campaign.update!(status: :cancelled)
redirect_to admin_soup_campaign_path(campaign), notice: "Campaign cancelled."
end

def toggle_unsubscribe
campaign = SoupCampaign.find(params[:id])
authorize campaign, :update? # Admin-only; reuse update? permission

recipient = campaign.soup_campaign_recipients.find(params[:recipient_id])

if recipient.unsubscribed?
# Re-subscribe: move back to sent if the campaign was already sent, otherwise pending
new_status = campaign.sent? ? :sent : :pending
recipient.update!(status: new_status)
else
recipient.update!(status: :unsubscribed)
end

render json: { ok: true, status: recipient.status }
rescue ActiveRecord::RecordNotFound
render json: { error: "Recipient not found" }, status: :not_found
end

private

def campaign_params
params.expect(soup_campaign: [ :name, :body, :footer, :unsubscribe_label, :image_url ])
end

def build_test_blocks(campaign, unsubscribe_url)
blocks = []

blocks << { type: "section", text: { type: "mrkdwn", text: "[TEST] #{campaign.body}" } }

if campaign.footer.present?
blocks << { type: "section", text: { type: "mrkdwn", text: campaign.footer } }
end

if campaign.image_url.present?
blocks << { type: "image", image_url: campaign.image_url, alt_text: campaign.name }
end

blocks << { type: "divider" }

blocks << {
type: "context",
elements: [
{ type: "mrkdwn", text: "#{campaign.unsubscribe_label} · <#{unsubscribe_url}|Unsubscribe>" }
]
}

blocks
end

def serialize_campaign(campaign)
{
id: campaign.id,
name: campaign.name,
body: campaign.body,
footer: campaign.footer,
unsubscribe_label: campaign.unsubscribe_label,
image_url: campaign.image_url,
status: campaign.status,
sent_at: campaign.sent_at&.iso8601,
scheduled_at: campaign.scheduled_at&.iso8601,
created_at: campaign.created_at.iso8601,
created_by: {
id: campaign.created_by_id,
display_name: campaign.created_by&.display_name,
avatar: campaign.created_by&.avatar
},
stats: campaign.recipient_stats,
progress: campaign.progress_percent
}
end

def serialize_recipient(recipient)
{
id: recipient.id,
slack_id: recipient.slack_id,
display_name: recipient.display_name,
status: recipient.status,
sent_at: recipient.sent_at&.iso8601,
error_message: recipient.error_message
}
end
end
38 changes: 38 additions & 0 deletions app/controllers/soup_campaign_unsubscribes_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Public unsubscribe endpoint — no authentication required, token-scoped.
# Allows any recipient to opt out of future Soup campaigns.
class SoupCampaignUnsubscribesController < ApplicationController
allow_unauthenticated_access only: %i[show create] # Token is the auth mechanism
skip_onboarding_redirect only: %i[show create] # Unsubscribe must work without an account
skip_after_action :verify_authorized, only: %i[show create] # No Pundit subject; token-gated
skip_after_action :verify_policy_scoped, only: %i[show create] # No collection query

def show
recipient = SoupCampaignRecipient.find_by(unsubscribe_token: params[:token])

if recipient.nil?
render inertia: "soup_campaign_unsubscribe/invalid"
return
end

render inertia: "soup_campaign_unsubscribe/show", props: {
campaign_name: recipient.soup_campaign.name,
already_unsubscribed: recipient.unsubscribed?,
token: params[:token]
}
end

def create
recipient = SoupCampaignRecipient.find_by(unsubscribe_token: params[:token])

if recipient.nil?
render inertia: "soup_campaign_unsubscribe/invalid"
return
end

recipient.update!(status: :unsubscribed) unless recipient.unsubscribed?

render inertia: "soup_campaign_unsubscribe/confirmed", props: {
campaign_name: recipient.soup_campaign.name
}
end
end
Loading