diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb
index d21092489..105a7cf9f 100644
--- a/app/controllers/submissions_controller.rb
+++ b/app/controllers/submissions_controller.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require 'uri'
-
class SubmissionsController < ApplicationController
before_action :set_form, only: %i[new create]
append_before_action :verify_authenticity_token, if: :form_requires_verification
@@ -26,112 +24,112 @@ def create
headers['Access-Control-Request-Method'] = '*'
headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization'
- # Catch SPAMMERS
- if @form && submission_params[:fba_directive].present?
- ActiveSupport::Notifications.instrument('spam_subverted') do |payload|
- payload[:request] = request
- end
-
- head :ok and return
- end
-
- # Check referer for unauthorized submissions
- # Use submission_params[:page] to identify admin preview pages even when session is not available via AJAX
- submission_referer = request.referer.presence || submission_params[:referer].presence
- is_admin_preview = submission_params[:page]&.start_with?('/admin/forms/') && submission_params[:page].include?('/example')
-
- if @form && current_user.blank? && !is_admin_preview && submission_referer.present? && !allowed_submission_referer?(submission_referer)
- error_options = {
- custom_params: {
- referer: submission_referer,
- },
- expected: true,
- }
- NewRelic::Agent.notice_error(ArgumentError, error_options)
-
- render json: {
- status: :unprocessable_content,
- messages: { submission: [t('errors.request.unauthorized_host')] },
- }, status: :unprocessable_content and return
- end
-
- # debug logging removed
@submission = Submission.new(submission_params)
@submission.form = @form
@submission.user_agent = request.user_agent
@submission.referer = submission_params[:referer]
@submission.page = submission_params[:page]
-
@submission.ip_address = request.remote_ip if @form.organization.enable_ip_address?
- create_in_local_database(@submission)
- end
-
- private
- def create_in_local_database(submission)
- if submission.form.enable_turnstile?
- if verify_turnstile(params[:cf_turnstile_response])
- submission.spam_prevention_mechanism = :turnstile
+ spam_result = SpamChecker.new.call(@submission, spam_context)
+ if spam_result.verdict == :reject
+ if Rails.configuration.x.silently_reject_spam
+ skip_save = true
+ @submission.uuid = SecureRandom.uuid
else
- submission.errors.add(:base, 'Turnstile verification failed')
+ # Loud rejection is intended for dev/test/CI only (see config.x.silently_reject_spam),
+ # so the response is deliberately explicit to aid debugging and test assertions.
+ respond_to do |format|
+ format.html { head :unprocessable_content }
+ format.json do
+ render json: {
+ status: :unprocessable_content,
+ messages: { submission: ['Submission rejected as spam'] },
+ }, status: :unprocessable_content
+ end
+ end
+ return
+ end
+ elsif spam_result.verdict == :surface
+ # Likely automated but potentially a false positive the user can correct
+ # (e.g. a missing/expired Turnstile token). Return a recoverable error so
+ # the client can prompt them to try again rather than silently dropping it.
+ respond_to do |format|
+ format.html { head :unprocessable_content }
+ format.json do
+ render json: {
+ status: :unprocessable_content,
+ messages: { submission: [t('errors.request.spam_surface')] },
+ }, status: :unprocessable_content
+ end
end
+ return
+ elsif spam_result.verdict == :flag
+ @submission.spam = true
+ @submission.spam_determination =
+ {
+ 'source' => 'automated',
+ 'reasons' => spam_result.flagged,
+ }
end
+ @submission.spam_prevention_mechanism = spam_result.applicable.join(', ')
+
respond_to do |format|
- if submission.errors.empty? && submission.save
+ if skip_save || @submission.save
format.html do
- redirect_to submit_touchpoint_path(submission.form),
+ redirect_to submit_touchpoint_path(@submission.form),
notice: 'Thank You. Response was submitted successfully.'
end
format.json do
- form_success_text = if submission.form.append_id_to_success_text?
- submission.form.success_text + "
Your Response ID is: #{submission.uuid[-12..-1]}"
+ form_success_text = if @submission.form.append_id_to_success_text?
+ (@submission.form.success_text || '') + "
Your Response ID is: #{@submission.uuid[-12..-1]}"
else
- submission.form.success_text
+ @submission.form.success_text
end
render json: {
- submission: {
- id: submission.uuid,
- answer_01: submission.answer_01,
- answer_02: submission.answer_02,
- answer_03: submission.answer_03,
- answer_04: submission.answer_04,
- answer_05: submission.answer_05,
- answer_06: submission.answer_06,
- answer_07: submission.answer_07,
- answer_08: submission.answer_08,
- answer_09: submission.answer_09,
- answer_10: submission.answer_10,
- answer_11: submission.answer_11,
- answer_12: submission.answer_12,
- answer_13: submission.answer_13,
- answer_14: submission.answer_14,
- answer_15: submission.answer_15,
- answer_16: submission.answer_16,
- answer_17: submission.answer_17,
- answer_18: submission.answer_18,
- answer_19: submission.answer_19,
- answer_20: submission.answer_20,
- answer_21: submission.answer_21,
- answer_22: submission.answer_22,
- answer_23: submission.answer_23,
- answer_24: submission.answer_24,
- answer_25: submission.answer_25,
- answer_26: submission.answer_26,
- answer_27: submission.answer_27,
- answer_28: submission.answer_28,
- answer_29: submission.answer_29,
- answer_30: submission.answer_30,
- form: {
- id: submission.form.uuid,
- name: submission.form.name,
- organization_name: submission.organization_name,
- success_text_heading: submission.form.success_text_heading,
- success_text: form_success_text,
- },
- },
- },
+ submission: {
+ id: @submission.uuid,
+ answer_01: @submission.answer_01,
+ answer_02: @submission.answer_02,
+ answer_03: @submission.answer_03,
+ answer_04: @submission.answer_04,
+ answer_05: @submission.answer_05,
+ answer_06: @submission.answer_06,
+ answer_07: @submission.answer_07,
+ answer_08: @submission.answer_08,
+ answer_09: @submission.answer_09,
+ answer_10: @submission.answer_10,
+ answer_11: @submission.answer_11,
+ answer_12: @submission.answer_12,
+ answer_13: @submission.answer_13,
+ answer_14: @submission.answer_14,
+ answer_15: @submission.answer_15,
+ answer_16: @submission.answer_16,
+ answer_17: @submission.answer_17,
+ answer_18: @submission.answer_18,
+ answer_19: @submission.answer_19,
+ answer_20: @submission.answer_20,
+ answer_21: @submission.answer_21,
+ answer_22: @submission.answer_22,
+ answer_23: @submission.answer_23,
+ answer_24: @submission.answer_24,
+ answer_25: @submission.answer_25,
+ answer_26: @submission.answer_26,
+ answer_27: @submission.answer_27,
+ answer_28: @submission.answer_28,
+ answer_29: @submission.answer_29,
+ answer_30: @submission.answer_30,
+ form: {
+ id: @submission.form.uuid,
+ name: @submission.form.name,
+ organization_name: @submission.organization_name,
+ success_text_heading: @submission.form.success_text_heading,
+ success_text: form_success_text,
+ },
+ },
+ },
status: :created
end
else
@@ -140,13 +138,15 @@ def create_in_local_database(submission)
format.json do
render json: {
status: :unprocessable_content,
- messages: submission.errors,
+ messages: @submission.errors,
}, status: :unprocessable_content
end
end
end
end
+ private
+
def set_form
if params[:form]
@short_uuid = params[:id].to_s
@@ -165,67 +165,19 @@ def set_form
def submission_params
permitted_fields = @form.questions.collect(&:answer_field)
permitted_fields << %i[language location_code referer hostname page query_string fba_directive]
- permitted_fields << %i[cf_turnstile_response]
params.require(:submission).permit(permitted_fields)
end
- def form_requires_verification
- @form.verify_csrf?
- end
-
- def allowed_submission_referer?(referer)
- allowlisted_prefixes = submission_whitelist_prefixes.compact
-
- return true if allowlisted_prefixes.any? { |prefix| referer.start_with?(prefix) }
-
- referer_host_matches_application?(referer)
+ def spam_context
+ {
+ referer: request.referer,
+ remote_ip: request.remote_ip,
+ cf_turnstile_response: params[:cf_turnstile_response],
+ root_url: root_url,
+ }
end
- def submission_whitelist_prefixes
- whitelist_attributes = %i[
- whitelist_url
- whitelist_url_1
- whitelist_url_2
- whitelist_url_3
- whitelist_url_4
- whitelist_url_5
- whitelist_url_6
- whitelist_url_7
- whitelist_url_8
- whitelist_url_9
- whitelist_test_url
- ]
-
- prefixes = whitelist_attributes.filter_map do |attr|
- value = @form.public_send(attr)
- value.presence
- end
- prefixes << root_url
- prefixes << request.base_url if request.base_url.present?
- prefixes << @form.organization&.url
- # Allow submissions from admin preview page for authorized users
- prefixes << "#{request.base_url}/admin/forms/" if current_user.present?
- prefixes
- end
-
- def referer_host_matches_application?(referer)
- uri = URI.parse(referer)
- uri.host == request.host
- rescue URI::InvalidURIError
- false
- end
-
- def verify_turnstile(response_token)
- secret_key = ENV.fetch('TURNSTILE_SECRET_KEY', nil)
- uri = URI('https://challenges.cloudflare.com/turnstile/v0/siteverify')
-
- response = Net::HTTP.post_form(uri, {
- 'secret' => secret_key,
- 'response' => response_token,
- 'remoteip' => request.remote_ip,
- })
-
- json = JSON.parse(response.body)
- json['success'] == true
+ def form_requires_verification
+ @form.verify_csrf?
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index b8e11ece5..ba820b3b2 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -244,5 +244,9 @@ def form_integrity_checksum(form:)
Digest::SHA256.base64digest(data_to_encode)
end
+ def boolean_to_text(value)
+ value ? 'Yes' : 'No'
+ end
+
delegate :fiscal_year_and_quarter, to: :FiscalYear
end
diff --git a/app/helpers/spam_helper.rb b/app/helpers/spam_helper.rb
new file mode 100644
index 000000000..8f741a66e
--- /dev/null
+++ b/app/helpers/spam_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# View helpers for presenting spam-check results in the admin interface.
+#
+# The human-readable copy lives with each check in app/services/spam_checks/;
+# these helpers just look up the check by its stored reason identifier (as
+# found in submission.spam_determination["reasons"]).
+module SpamHelper
+ def spam_reason_label(reason)
+ check = SpamChecks::Base.for(reason)
+ check ? check.label : reason.to_s.humanize
+ end
+
+ def spam_reason_description(reason)
+ SpamChecks::Base.for(reason)&.description
+ end
+end
diff --git a/app/services/spam_checker.rb b/app/services/spam_checker.rb
new file mode 100644
index 000000000..7ef8681ba
--- /dev/null
+++ b/app/services/spam_checker.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+# Service object to run spam checks on a submission and return an overall outcome.
+#
+# Required context keys:
+# :referer — String referer to validate (may be nil/blank)
+# :root_url — String root URL of the running application (may be nil)
+# :cf_turnstile_response — String token from the client (may be nil/blank)
+# :remote_ip — String remote IP of the submitter
+class SpamChecker
+ DEFAULT_SPAM_CHECKS = SpamChecks::Base.checks
+
+ # Value object returned from SpamChecker#call. Wraps the per-check results
+ # and exposes the overall #verdict derived from them.
+ class SpamResults
+ attr_reader :results
+
+ def initialize(results)
+ @results = Array(results)
+ end
+
+ # Overall outcome derived from all checks, in precedence order:
+ # a hard :reject wins, then :surface (maybe-bot-but-user-fixable, prompt a
+ # retry), then :flag, otherwise :pass.
+ def verdict
+ if results.any?(&:reject?)
+ :reject
+ elsif results.any?(&:surface?)
+ :surface
+ elsif results.any?(&:flag?)
+ :flag
+ else
+ :pass
+ end
+ end
+
+ def flagged
+ results.filter(&:flag?).map(&:name)
+ end
+
+ def applicable
+ results.reject(&:not_applied?).map(&:name)
+ end
+ end
+
+ def initialize(spam_checks: DEFAULT_SPAM_CHECKS)
+ @spam_checks = spam_checks
+ end
+
+ def call(submission, context)
+ # Run ALL applicable checks (not short-circuited) so the full outcome
+ # vector is available for redundancy/co-occurrence analysis.
+ spam_results = SpamResults.new(run_spam_checks(submission, context))
+ record_spam_results(submission.form.id, spam_results.verdict, spam_results.results)
+
+ spam_results
+ end
+
+ private
+
+ # Internal-only readers. These are set once in #initialize and used only
+ # within the object, so they are not part of the public interface.
+ attr_reader :spam_checks
+
+ def run_spam_checks(submission, context)
+ spam_checks.map do |check_class|
+ check_class.new(submission: submission, context:).call
+ end
+ end
+
+ # Record spam telemetry.
+ def record_spam_results(form_id, verdict, spam_results)
+ results = Array(spam_results)
+ results_by_check = SpamChecks::Base.results_to_h(results)
+
+ SpamMetrics.record(form_id:, verdict:, results: results_by_check)
+ end
+end
diff --git a/app/services/spam_checks/base.rb b/app/services/spam_checks/base.rb
new file mode 100644
index 000000000..0efd4b149
--- /dev/null
+++ b/app/services/spam_checks/base.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module SpamChecks
+ # Base defines the interface (via duck typing) that every spam check
+ # implements. A check is a small object constructed with the request-derived
+ # context it needs, exposing a single #call that returns a Result.
+ #
+ # Subclasses override:
+ # - #applicable? (optional) — should this check run for this submission?
+ # - #evaluate (required) — run the spam check, returning one of :reject :flag :surface :pass
+ class Base
+ # status is one of :pass, :reject, :flag, :surface, :not_applied
+ Result = Data.define(:name, :status) do
+ def reject? = status == :reject
+ def flag? = status == :flag
+ def surface? = status == :surface
+ def not_applied? = status == :not_applied
+ end
+
+ # Reduce an array of Results to a plain { name => status_string } hash,
+ # suitable for a notification payload or JSONB persistence (no object
+ # references, safe to serialize / enqueue).
+ def self.results_to_h(results)
+ results.each_with_object({}) do |result, hash|
+ hash[result.name] = result.status.to_s
+ end
+ end
+
+ # Human-readable label for this check, shown in the admin interface.
+ # Defaults to a humanized version of the check name; subclasses set LABEL.
+ def self.label
+ const_defined?(:LABEL, false) ? const_get(:LABEL) : check_name.humanize
+ end
+
+ # Plain-language explanation of why this check flags a response.
+ # Subclasses set DESCRIPTION; nil when unset.
+ def self.description
+ const_get(:DESCRIPTION) if const_defined?(:DESCRIPTION, false)
+ end
+
+ # The check's persisted identifier, matching the #name written to
+ # submission.spam_determination["reasons"] (e.g. "honeypot").
+ def self.check_name
+ const_get(:CHECK_NAME)
+ end
+
+ # Look up a subclass by its check name (e.g. "honeypot"). Returns nil for
+ # unknown names.
+ def self.for(check_name)
+ checks.find { |klass| klass.check_name == check_name.to_s }
+ end
+
+ # The known spam check subclasses. Listed explicitly (rather than via
+ # .subclasses) so lookups work regardless of autoloading/eager-load state.
+ def self.checks
+ [SpamChecks::Honeypot, SpamChecks::Referer, SpamChecks::Turnstile]
+ end
+
+ def initialize(submission:, context: {})
+ @submission = submission
+ @context = context
+ end
+
+ def call
+ applicable? ? result(evaluate) : result(:not_applied)
+ end
+
+ private
+
+ attr_reader :submission, :context
+
+ def result(status)
+ Result.new(name: name, status: status)
+ end
+
+ def name
+ self.class.check_name
+ end
+
+ def applicable?
+ true
+ end
+
+ def evaluate
+ raise NotImplementedError, "#{self.class} must implement #evaluate"
+ end
+ end
+end
diff --git a/app/services/spam_checks/honeypot.rb b/app/services/spam_checks/honeypot.rb
new file mode 100644
index 000000000..ca9b2ffe8
--- /dev/null
+++ b/app/services/spam_checks/honeypot.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module SpamChecks
+ # Flags submissions where the hidden honeypot field (fba_directive) was
+ # filled in. Legitimate users never see or populate this field, so any
+ # value indicates an automated submission.
+ #
+ # Required context keys: None
+ class Honeypot < Base
+ # Persisted identifier written to submission.spam_determination["reasons"].
+ CHECK_NAME = 'honeypot'
+ LABEL = 'Hidden field was filled in'
+ DESCRIPTION = 'A hidden field that real people never see was filled in. ' \
+ 'This almost always indicates an automated (bot) submission.'
+
+ private
+
+ def evaluate
+ submission.fba_directive.present? ? :reject : :pass
+ end
+ end
+end
diff --git a/app/services/spam_checks/referer.rb b/app/services/spam_checks/referer.rb
new file mode 100644
index 000000000..a5c31714e
--- /dev/null
+++ b/app/services/spam_checks/referer.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module SpamChecks
+ # Flags submissions whose referer is not on the form's allowlist, and is
+ # neither the Touchpoints application itself nor the form's organization.
+ #
+ # Required context keys:
+ # :referer — String referer to validate (may be nil/blank)
+ # :root_url — String root URL of the running application (may be nil)
+ class Referer < Base
+ # Persisted identifier written to submission.spam_determination["reasons"].
+ CHECK_NAME = 'referer'
+ LABEL = 'Unrecognized referring domain'
+ DESCRIPTION = 'The response was submitted from a domain that is not on ' \
+ "this form's allowlist. This can indicate spam, but it may " \
+ 'also happen when an embedded form is missing a domain ' \
+ 'from its allowlist.'
+
+ private
+
+ def applicable?
+ referer.present?
+ end
+
+ # Why flag on failure instead of reject?
+ # Because some failures could be due to form misconfiguration,
+ # specifically if the whitelist is incomplete for embedded forms.
+ def evaluate
+ is_allowed = allowlisted? ||
+ from_touchpoints? ||
+ from_form_organization?
+ is_allowed ? :pass : :flag
+ end
+
+ def allowlisted?
+ whitelist_prefixes.any? { |prefix| referer.start_with?(prefix) }
+ end
+
+ def from_touchpoints?
+ root_url = context[:root_url]
+ return false if root_url.blank?
+
+ referer.start_with?(root_url)
+ end
+
+ def from_form_organization?
+ org_url = submission.form.organization&.url
+ return false if org_url.blank?
+
+ referer.start_with?(org_url)
+ end
+
+ # Referer header
+ def referer
+ context[:referer]
+ end
+
+ def whitelist_prefixes
+ whitelist_attributes = %i[
+ whitelist_url
+ whitelist_url_1
+ whitelist_url_2
+ whitelist_url_3
+ whitelist_url_4
+ whitelist_url_5
+ whitelist_url_6
+ whitelist_url_7
+ whitelist_url_8
+ whitelist_url_9
+ whitelist_test_url
+ ]
+
+ whitelist_attributes.filter_map do |attr|
+ value = submission.form.public_send(attr)
+ value.presence
+ end
+ end
+ end
+end
diff --git a/app/services/spam_checks/turnstile.rb b/app/services/spam_checks/turnstile.rb
new file mode 100644
index 000000000..3269d5cac
--- /dev/null
+++ b/app/services/spam_checks/turnstile.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'net/http'
+require 'json'
+
+module SpamChecks
+ # Verifies a Cloudflare Turnstile token, treating a failed verification as
+ # spam. Only applicable when the form has Turnstile enabled.
+ #
+ # The actual HTTP call is delegated to a `verifier` collaborator so this
+ # check can be unit-tested without network access. The verifier must respond
+ # to #verify(token:, remote_ip:) and return one of :pass, :reject, :flag, or
+ # :surface (maybe-bot-but-user-fixable — prompt the user to try again).
+ #
+ # Required context keys:
+ # :cf_turnstile_response — String token from the client (may be nil/blank)
+ # :remote_ip — String remote IP of the submitter
+ class Turnstile < Base
+ # Persisted identifier written to submission.spam_determination["reasons"].
+ CHECK_NAME = 'turnstile'
+ LABEL = 'Failed bot-detection challenge'
+ DESCRIPTION = 'The response did not pass the Cloudflare Turnstile ' \
+ 'bot-detection challenge.'
+
+ private
+
+ def applicable?
+ submission.form.enable_turnstile?
+ end
+
+ def evaluate
+ verifier.verify(token: context[:cf_turnstile_response], remote_ip: context[:remote_ip])
+ end
+
+ def verifier
+ context.fetch(:verifier, TurnstileVerifier)
+ end
+ end
+
+ # Thin wrapper around the Cloudflare Turnstile siteverify endpoint.
+ module TurnstileVerifier
+ SITEVERIFY_URI = URI('https://challenges.cloudflare.com/turnstile/v0/siteverify')
+
+ def self.verify(token:, remote_ip:)
+ # Might be a bot but fixable by user so give them a chance to fix
+ return :surface if token.blank?
+
+ response = Net::HTTP.post_form(
+ SITEVERIFY_URI,
+ {
+ 'secret' => ENV.fetch('TURNSTILE_SECRET_KEY', nil),
+ 'response' => token,
+ 'remoteip' => remote_ip,
+ },
+ )
+
+ result = JSON.parse(response.body)
+ return :pass if result['success'] == true
+
+ if result['error-codes'].intersect?(%w[missing-input-secret invalid-input-secret])
+ # Don't enforce Turnstile if Touchpoints is misconfigured
+ Rails.logger.error 'Turnstile secret key is misconfigured'
+ :pass
+ elsif result['error-codes'].intersect?(%w[timeout-or-duplicate invalid-input-response])
+ # Might be a bot but fixable by user so give them a chance to fix
+ :surface
+ else
+ # Any other errors suggest Turnstile is having problems, fail open
+ Rails.logger.warn "Turnstile verification returned failure with error codes #{result['error-codes'].join(', ')}"
+ :pass
+ end
+ rescue StandardError => e
+ Rails.logger.warn "Turnstile verification call failed with message: #{e.message}"
+ :pass
+ end
+ end
+end
diff --git a/app/services/spam_metrics.rb b/app/services/spam_metrics.rb
new file mode 100644
index 000000000..95c8c35ce
--- /dev/null
+++ b/app/services/spam_metrics.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# SpamMetrics records the observability side effects for a spam check run on a submission.
+# It is a plain object so the logic is unit-testable and is called directly from SpamChecker.
+#
+# For now, spam telemetry is a TIME-BOXED analysis (see ADR 0001): we emit
+# NewRelic custom events + counters rather than persisting to the database.
+# NewRelic handles storage and retention.
+module SpamMetrics
+ module_function
+
+ # verdict: Symbol of :pass, :reject, :flag, or :surface
+ # results: Hash of { check_name => status_string }, e.g.
+ # { "honeypot" => "reject", "referer" => "pass", "turnstile" => "not_applied" }
+ def record(form_id:, verdict:, results:)
+ return unless defined?(NewRelic::Agent)
+
+ increment_counters(verdict, results)
+ record_custom_event(form_id, results)
+ end
+
+ # One counter per check outcome so we can chart how many submissions each
+ # prevention method blocks (and how often it is applied), plus a total.
+ def increment_counters(verdict, results)
+ NewRelic::Agent.increment_metric("Custom/Spam/#{verdict}")
+ results.each do |check, status|
+ NewRelic::Agent.increment_metric("Custom/Spam/#{check}/#{status}")
+ end
+ end
+
+ # One custom event per submission, with each check flattened to its own
+ # attribute (e.g. honeypot: "reject", referer: "pass") so NRQL can FACET on
+ # check pairs to answer the redundancy / co-occurrence question.
+ def record_custom_event(form_id, results)
+ NewRelic::Agent.record_custom_event(
+ 'SpamCheck',
+ { form_id: form_id }.merge(results),
+ )
+ end
+end
diff --git a/app/views/admin/submissions/_spam_alert.html.erb b/app/views/admin/submissions/_spam_alert.html.erb
new file mode 100644
index 000000000..f0e5a79d4
--- /dev/null
+++ b/app/views/admin/submissions/_spam_alert.html.erb
@@ -0,0 +1,66 @@
+<%# Explains that a response is spam, plus how and why it was flagged. %>
+<%# Expects: submission %>
+<% determination = submission.spam_determination %>
+<% source = determination.present? ? determination["source"] : nil %>
+<% reasons = determination.present? ? Array(determination["reasons"]) : [] %>
+<% if source == 'manual'%>
+
+ This response was marked as spam by a member of your team. +
++ Why it was marked: +
+This response is flagged. @@ -17,7 +20,7 @@
This response is archived. @@ -197,7 +200,7 @@ Flagged