Skip to content
Merged
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
5 changes: 4 additions & 1 deletion app/controllers/api/submissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ def create_submissions(template, params)
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)

submitters.each do |submitter|
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
if submitter.completed_at?
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request, {},
current_user)
end
end

submissions
Expand Down
75 changes: 46 additions & 29 deletions app/controllers/api/submitters_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,37 +37,12 @@ def update
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
end

submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']

normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
)

Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
default_submitter_uuid: @submitter.uuid)
normalized_params, new_attachments = normalize_and_prepare_params
old_values = @submitter.values.dup

assign_submitter_attrs(@submitter, normalized_params)

ApplicationRecord.transaction do
Submissions::NormalizeParamUtils.save_default_value_attachments!(new_attachments, [@submitter])

@submitter.save!

@submitter.submission.save!

SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
end

if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id)
elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter])
end

SearchEntries.enqueue_reindex(@submitter)
save_submitter_and_track_changes(new_attachments, old_values)
handle_post_save_actions(normalized_params)

render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:)
Expand Down Expand Up @@ -170,6 +145,48 @@ def filter_submitters(submitters, params)
maybe_filder_by_completed_at(submitters, params)
end

def normalize_and_prepare_params
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']

normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
)

Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
default_submitter_uuid: @submitter.uuid)

[normalized_params, new_attachments]
end

def save_submitter_and_track_changes(new_attachments, old_values)
ApplicationRecord.transaction do
Submissions::NormalizeParamUtils.save_default_value_attachments!(new_attachments, [@submitter])

@submitter.save!

@submitter.submission.save!

Submitters::SubmitValues.track_form_update(@submitter, old_values, request, current_user)

return unless @submitter.completed_at?

SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request, {}, current_user)
end
end

def handle_post_save_actions(normalized_params)
if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id)
elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter])
end

SearchEntries.enqueue_reindex(@submitter)
end

def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/submit_form_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def update
status: :unprocessable_entity
end

Submitters::SubmitValues.call(@submitter, params, request)
Submitters::SubmitValues.call(@submitter, params, request, current_user, validate_required: true)

head :ok
rescue Submitters::SubmitValues::RequiredFieldError => e
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/submitters_request_changes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ def request_changes
@submitter,
'request_changes',
request,
{ reason: params[:reason], requested_by: current_user.id }
{ reason: params[:reason], requested_by: current_user.id },
current_user
)
end

Expand Down
24 changes: 23 additions & 1 deletion app/jobs/send_form_completed_webhook_request_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ def perform(params = {})

ActiveStorage::Current.url_options = Docuseal.default_url_options

# Build the payload with submission events for granular audit tracking
webhook_data = Submitters::SerializeForWebhook.call(submitter)

# Add submission events for CareerPlug ATS integration
webhook_data['submission_events'] = serialize_submission_events(submitter.submission)

resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed',
data: Submitters::SerializeForWebhook.call(submitter))
data: webhook_data)

if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
Expand All @@ -31,4 +37,20 @@ def perform(params = {})
})
end
end

private

# Serialize submission events for webhook payload
# Returns array of event hashes with field-level change tracking
def serialize_submission_events(submission)
submission.submission_events.order(:event_timestamp).map do |event|
{
id: event.id,
event_type: event.event_type,
event_timestamp: event.event_timestamp.iso8601,
user_id: event.user_id,
data: event.data
}
end
end
end
7 changes: 6 additions & 1 deletion app/models/submission_event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@
# updated_at :datetime not null
# submission_id :integer not null
# submitter_id :integer
# user_id :bigint
#
# Indexes
#
# index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
# index_submission_events_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (submission_id => submissions.id)
# fk_rails_... (submitter_id => submitters.id)
# fk_rails_... (user_id => users.id)
#
class SubmissionEvent < ApplicationRecord
belongs_to :submission
belongs_to :user, optional: true
has_one :account, through: :submission
belongs_to :submitter, optional: true

Expand Down Expand Up @@ -55,7 +59,8 @@ class SubmissionEvent < ApplicationRecord
complete_form: 'complete_form',
decline_form: 'decline_form',
request_changes: 'request_changes',
api_complete_form: 'api_complete_form'
api_complete_form: 'api_complete_form',
form_update: 'form_update'
}, scope: false

private
Expand Down
61 changes: 60 additions & 1 deletion app/services/export_submission_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ def build_payload
}
end,
created_at: submission.created_at,
updated_at: submission.updated_at
updated_at: submission.updated_at,
# Include form field values for each submitter
values: build_values_array,
# Include granular submission events for audit trail
submission_events: build_submission_events_array
}
end

Expand All @@ -77,4 +81,59 @@ def submission_status
'pending'
end
end

# Build array of form field values from all submitters
# Returns array of {field: name, value: value} hashes
def build_values_array
submission.submitters.flat_map do |submitter|
build_submitter_values(submitter)
end
end

# Build values for a single submitter
def build_submitter_values(submitter)
fields = submission.template_fields.presence || submission.template&.fields || []
attachments_index = submitter.attachments.index_by(&:uuid)

fields.filter_map do |field|
next if field['submitter_uuid'] != submitter.uuid
next if field['type'] == 'heading'

field_name = field['name'].presence || "#{field['type'].titleize} Field"
next unless submitter.values.key?(field['uuid']) || submitter.completed_at?

value = fetch_field_value(field, submitter.values[field['uuid']], attachments_index)

{ field: field_name, value: }
end
end

# Build array of submission events for audit trail
def build_submission_events_array
submission.submission_events.order(:event_timestamp).map do |event|
{
id: event.id,
event_type: event.event_type,
event_timestamp: event.event_timestamp.iso8601,
data: event.data
}
end
end

# Fetch the value for a field, handling special types
def fetch_field_value(field, value, attachments_index)
if field['type'].in?(%w[image signature initials stamp payment])
rails_storage_proxy_url(attachments_index[value])
elsif field['type'] == 'file'
Array.wrap(value).compact_blank.filter_map { |e| rails_storage_proxy_url(attachments_index[e]) }
else
value
end
end

def rails_storage_proxy_url(attachment)
return if attachment.blank?

ActiveStorage::Blob.proxy_url(attachment.blob)
end
end
7 changes: 7 additions & 0 deletions db/migrate/20260121191632_add_user_id_to_submission_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddUserIdToSubmissionEvents < ActiveRecord::Migration[8.0]
def change
add_reference :submission_events, :user, null: true, foreign_key: true
end
end
14 changes: 9 additions & 5 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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_11_07_175502) do
ActiveRecord::Schema[8.0].define(version: 2026_01_21_191632) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
Expand Down Expand Up @@ -181,7 +181,7 @@
t.datetime "created_at", null: false
t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime"
t.index ["email"], name: "index_email_events_on_email"
t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))"
t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))"
t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable"
t.index ["message_id"], name: "index_email_events_on_message_id"
end
Expand Down Expand Up @@ -290,10 +290,11 @@
t.tsvector "tsvector", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_submission", where: "((record_type)::text = 'Submission'::text)", using: :gin
t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_submitter", where: "((record_type)::text = 'Submitter'::text)", using: :gin
t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_template", where: "((record_type)::text = 'Template'::text)", using: :gin
t.index ["account_id"], name: "index_search_entries_on_account_id"
t.index ["record_id", "record_type"], name: "index_search_entries_on_record_id_and_record_type", unique: true
t.index ["tsvector"], name: "index_search_entries_on_account_id_tsvector_submission", where: "((record_type)::text = 'Submission'::text)", using: :gin
t.index ["tsvector"], name: "index_search_entries_on_account_id_tsvector_submitter", where: "((record_type)::text = 'Submitter'::text)", using: :gin
t.index ["tsvector"], name: "index_search_entries_on_account_id_tsvector_template", where: "((record_type)::text = 'Template'::text)", using: :gin
end

create_table "submission_events", force: :cascade do |t|
Expand All @@ -304,9 +305,11 @@
t.datetime "event_timestamp", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "user_id"
t.index ["created_at"], name: "index_submission_events_on_created_at"
t.index ["submission_id"], name: "index_submission_events_on_submission_id"
t.index ["submitter_id"], name: "index_submission_events_on_submitter_id"
t.index ["user_id"], name: "index_submission_events_on_user_id"
end

create_table "submissions", force: :cascade do |t|
Expand Down Expand Up @@ -497,6 +500,7 @@
add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id"
add_foreign_key "submission_events", "submissions"
add_foreign_key "submission_events", "submitters"
add_foreign_key "submission_events", "users"
add_foreign_key "submissions", "templates"
add_foreign_key "submissions", "users", column: "created_by_user_id"
add_foreign_key "submitters", "submissions"
Expand Down
6 changes: 5 additions & 1 deletion lib/send_webhook_request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ def call(webhook_url, event_type:, data:)
Faraday.post(uri) do |req|
req.headers['Content-Type'] = 'application/json'
req.headers['User-Agent'] = USER_AGENT
req.headers.merge!(webhook_url.secret.to_h) if webhook_url.secret.present?

# Send webhook secret headers from the configured secret hash
webhook_url.secret&.each do |header_name, header_value|
req.headers[header_name] = header_value
end

req.body = {
event_type: event_type,
Expand Down
8 changes: 5 additions & 3 deletions lib/submission_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ def build_tracking_param(submitter, event_type = 'click_email')
).first(TRACKING_PARAM_LENGTH)
end

def create_with_tracking_data(submitter, event_type, request, data = {})
SubmissionEvent.create!(submitter:, event_type:, data: {
def create_with_tracking_data(submitter, event_type, request, data = {}, user = nil)
user ||= request.env['warden']&.user(:user)

SubmissionEvent.create!(submitter:, event_type:, user:, data: {
ip: request.remote_ip,
ua: request.user_agent,
sid: request.session.id.to_s,
uid: request.env['warden'].user(:user)&.id,
uid: user&.id,
**data
}.compact_blank)
end
Expand Down
Loading
Loading