Skip to content

Add SMS error handling, retry logic, and Redis-backed rate limiting#4

Merged
dnplkndll merged 58 commits intokencovefrom
sms-error-handling-and-redis-rate-limit
Feb 28, 2026
Merged

Add SMS error handling, retry logic, and Redis-backed rate limiting#4
dnplkndll merged 58 commits intokencovefrom
sms-error-handling-and-redis-rate-limit

Conversation

@dnplkndll
Copy link
Copy Markdown

@dnplkndll dnplkndll commented Feb 28, 2026

Summary

  • SendSms: Add open/read timeouts (8s/15s), return parsed Twilio JSON on success for SID capture
  • SendSubmitterInvitationSmsJob: Disable Sidekiq's 25 blind retries, implement 5-attempt exponential backoff with error logging, store Twilio SID in SubmissionEvent.data
  • RateLimit: Switch from per-process MemoryStore to Redis-backed cache (graceful fallback to MemoryStore if REDIS_URL unavailable)
  • Add specs for all three modules

Test plan

  • CI passes (RSpec, RuboCop, Brakeman)
  • SMS delivery works with valid Twilio credentials
  • Failed SMS triggers retry with exponential backoff (check Sidekiq scheduled jobs)
  • Rate limiting works across Puma workers (verify via Redis keys)
  • Fallback to MemoryStore when REDIS_URL is not set

🤖 Generated with Claude Code


Note

Medium Risk
Touches form access control and download link generation, plus email/SMS delivery and rendering, so regressions could impact security-sensitive flows and outbound communications. Risk is moderated by mostly additive/guard-rail changes but spans many entrypoints (API, form UI, mailers, jobs).

Overview
Hardens submitter form/download flows and expands invite automation. Form and download endpoints now gate access through Submitters::AuthorizedForForm (including link/email 2FA paths), adjust who counts as the current submitter via Ability checks, and trigger Submitters::SubmitValues.maybe_invite_via_field when API-driven completion occurs.

Changes attachment URL handling and template recipient rules. Multiple controllers/views switch from ActiveStorage::Blob.proxy_url to a new proxy_path helper, and template submitters gain invite_via_field_uuid support (permitted params + UI), affecting which recipients are considered “undefined” and eligible for “sign yourself”.

Improves messaging tooling and delivery robustness. Adds a new Tiptap-based markdown-editor for configurable email bodies (with variable insertion), updates mailer links to respect per-account custom domains, adds clearer canvas-blocked signature errors, and upgrades SendSubmitterInvitationSmsJob to no Sidekiq retries with explicit exponential backoff + Twilio SID capture.

Misc hardening/maintenance. Enforces HTTPS webhooks on default port, validates URL downloads when uploading templates, redacts/placeholder-seeds secret fields in settings forms, updates favicon/manifest, and bumps/cleans dependencies (including removing rails_autolink).

Written by Cursor Bugbot for commit a148f47. Configure here.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added Markdown editor for email and document template bodies with formatting toolbar (bold, italic, underline, link, undo, redo).
    • Canvas blocking detection for signature fields with informative messaging when browser privacy settings restrict drawing.
    • Optional timestamps with seconds precision in audit trails and submission records.
  • Bug Fixes

    • Fixed password fields exposing actual values in settings forms; now masked with placeholder text.
    • Enhanced SMS job reliability with exponential backoff retries and Twilio tracking.
    • Improved URL validation for HTTPS enforcement on specified ports.
  • Security

    • Added SQL injection prevention for search queries.
    • Strengthened form authorization checks across controllers.

omohokcoj and others added 18 commits February 21, 2026 14:32
Brings in 54 upstream commits including:
- v2.3.4: SQL wildcard injection fix, CanCan authorization on downloads,
  2FA refactoring, readonly field validation, credential hiding
- v2.3.5: Rich text/markdown email editor, conditional signing party
  invitation, radio select in formula fields, Cloudflare R2 fix

Conflict resolutions:
- submissions_download_controller.rb: Kept upstream CanCan authorization
  check + Kencove's Rails.logger (instead of Rollbar)
- Gemfile.lock: Re-bundled to include both upstream deps and Kencove's
  omniauth/oauth2 gems

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- SendSms: add open_timeout (8s) and read_timeout (15s) matching webhook
  config, return parsed Twilio JSON on success for SID capture
- SendSubmitterInvitationSmsJob: disable Sidekiq's 25 blind retries,
  implement manual 5-attempt exponential backoff with error logging,
  store Twilio SID in SubmissionEvent.data on success
- RateLimit: switch STORE from per-process MemoryStore to Redis-backed
  cache (falls back to MemoryStore if REDIS_URL not set or unavailable)
- Add specs for SendSms, SendSubmitterInvitationSmsJob, and RateLimit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 28, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive markdown editor component for rich text editing, implements centralized authorization checks via a new Submitters::AuthorizedForForm module, adds field-based invitation workflows for submitter management, enhances security through SQL injection prevention and HTTPS validation improvements, and refactors email handling with custom domain support and timestamp precision options.

Changes

Cohort / File(s) Summary
Configuration & Build
.rubocop.yml, Dockerfile, Gemfile, package.json, config/routes.rb, config/dotenv.rb
Updated linting rules, added VIPS image processing config, removed rails_autolink gem, added TipTap markdown editor dependencies (v3), and adjusted timestamp server route conditionals.
Authorization Module
lib/submitters/authorized_for_form.rb
New module centralizing 2FA authorization logic with methods for email/link-based checks and cookie/token verification across multiple form submission contexts.
Markdown Editor Component
app/javascript/elements/markdown_editor.js, app/javascript/application.js, app/javascript/application.scss, app/views/personalization_settings/_markdown_editor.html.erb
Introduced new TipTap-based rich text editor component with toolbar (bold, italic, underline, link), keyboard shortcuts, link tooltip UI, variable insertion, and integrated styling.
Icon Partials
app/views/icons/_arrow_back_up.html.erb, app/views/icons/_arrow_forward_up.html.erb, app/views/icons/_bold.html.erb, app/views/icons/_italic.html.erb, app/views/icons/_underline.html.erb
Added new SVG icon partials for markdown editor toolbar buttons and navigation arrows.
Controller Authorization Enforcement
app/controllers/submit_form_controller.rb, app/controllers/submit_form_decline_controller.rb, app/controllers/submit_form_download_controller.rb, app/controllers/submit_form_draw_signature_controller.rb, app/controllers/submissions_download_controller.rb, app/controllers/submit_form_values_controller.rb
Integrated Submitters::AuthorizedForForm checks across submission workflows to gate form access, declining, downloads, and signature drawing based on authorization status.
Submitter & Invitation Management
app/controllers/api/submitters_controller.rb, app/controllers/api/submissions_controller.rb, app/controllers/submit_form_invite_controller.rb, app/controllers/templates_recipients_controller.rb, lib/submitters/submit_values.rb
Added field-based invitation workflow (invite_via_field_uuid), email normalization in invite creation, and new maybe_invite_via_field helper to create submitters from field mappings.
Template & Params Management
app/controllers/api/templates_controller.rb, app/controllers/templates_controller.rb, lib/templates.rb, lib/templates/clone.rb
Extended permitted params to include invite_via_field_uuid, updated submitter filtering to exclude field-invited submitters, and refactored UUID replacement during template cloning.
SMS Job & Error Handling
app/jobs/send_submitter_invitation_sms_job.rb, lib/send_sms.rb
Added retry logic with exponential backoff, MAX_ATTEMPTS limit, per-attempt tracking, twilio_sid capture in events, timeout configuration, and improved error handling for network failures.
Email & Mailer Updates
app/mailers/submitter_mailer.rb, app/views/submitter_mailer/*.html.erb, lib/replace_email_variables.rb
Added custom domain support via maybe_set_custom_domain, centralized URL options building, and updated email links to explicitly include host parameter.
Personalization & Email Config
app/views/personalization_settings/_email_body_field.html.erb, app/views/personalization_settings/_submitter_completed_email_form.html.erb, app/views/submissions/_send_email_base.html.erb, app/views/templates_preferences/_*email_form.html.erb
Replaced textarea-based email editing with markdown editor component, integrated AccountConfig::EMAIL_VARIABLES for template placeholders, and simplified form field structures.
Submission & Timestamp Features
app/views/submissions/show.html.erb, app/views/submissions/_value.html.erb, app/views/submit_form/show.html.erb, lib/submissions/generate_result_attachments.rb, lib/submissions/generate_preview_attachments.rb, app/models/account_config.rb, lib/submitters/form_configs.rb
Added WITH_TIMESTAMP_SECONDS config and time_format logic to enable/disable seconds in timestamps across audit trails, PDFs, and form submissions.
Storage & Credentials Security
app/views/storage_settings/_*.html.erb, app/views/email_smtp_settings/index.html.erb
Removed pre-filling of sensitive credential fields (passwords, access keys, credentials) and added masked placeholders for better security UX.
URL & Query Parameter Handling
app/views/layouts/application.html.erb, app/views/shared/_search_input.html.erb, app/views/submissions_dashboard/index.html.erb, app/views/submissions_filters/_*.html.erb, app/views/templates/show.html.erb, app/views/webhook_settings/show.html.erb
Standardized parameter handling across templates using request.query_parameters instead of params.to_unsafe_h for cleaner parameter management.
Active Storage URL Generation
config/initializers/active_storage.rb, app/controllers/submissions_download_controller.rb, app/controllers/submit_form_download_controller.rb, app/controllers/template_documents_controller.rb, app/views/submissions/show.html.erb, lib/submitters/normalize_values.rb
Added new proxy_path method to generate signed proxy paths (instead of full URLs), changing ActiveStorage URL handling across download and preview flows.
Data Validation & Security
lib/submissions.rb, lib/template_folders.rb, lib/submitters.rb, lib/submissions/create_from_submitters.rb, lib/send_webhook_request.rb, lib/send_test_webhook_request.rb, lib/download_utils.rb
Enhanced SQL LIKE injection prevention, added file param validation in attachment creation, tightened HTTPS/port validation for webhooks, and added validate parameter to URL downloads.
Frontend Form & UI Updates
app/javascript/submission_form/*.vue, app/javascript/template_builder/*.vue, app/javascript/elements/toggle_attribute.js, app/javascript/elements/toggle_classes.js, app/javascript/submission_form/validate_signature.js
Added fetchOptions propagation for network requests, canvas blocking detection with user feedback, improved field type detection, and refactored toggle attribute logic with value normalization.
Template Builder & View Helpers
app/javascript/template_builder/builder.vue, app/javascript/template_builder/formula_modal.vue, app/javascript/template_builder/page.vue, app/views/templates_preferences/_recipients.html.erb, app/views/templates_share_link/show.html.erb, app/views/templates/_submission.html.erb, app/views/templates_prefillable_fields/_form.html.erb
Filtered undefined submitters, added numeric field detection, disabled select-mode during custom field drawing, expanded recipient preferences UI with field-based invitation controls, and updated label terminology.
Markdown Processing
lib/markdown_to_html.rb
Replaced simple regex-based link parsing with full markdown-to-HTML pipeline including inline tokenizer, sanitization, auto-link detection, emphasis/strong/code handling, and bracket preservation.
Rate Limiting & Caching
lib/rate_limit.rb
Added conditional Redis cache store support with fallback to MemoryStore based on REDIS_URL environment variable.
Internationalization & Text
config/locales/i18n.yml, app/javascript/submission_form/i18n.js, app/javascript/template_builder/i18n.js, app/views/pwa/manifest.json.erb, app/views/shared/_meta.html.erb, app/views/start_form/_policy.html.erb, app/views/submitter_mailer/_custom_content.html.erb
Added canvas blocking alert messages across locales, added font styling keys (bold, italic, underline, undo, redo), fixed grammar/typos, changed favicon references, and simplified content rendering by removing auto-link wrapper.
Test Infrastructure & Specs
spec/rails_helper.rb, spec/jobs/*webhook*_spec.rb, spec/jobs/send_submitter_invitation_sms_job_spec.rb, spec/lib/rate_limit_spec.rb, spec/lib/send_sms_spec.rb, spec/system/*.rb
Added TimeHelpers inclusion for test timing control, added freeze_time hooks to webhook job specs for deterministic timestamps, introduced comprehensive SMS job and rate limiting test coverage, and updated signing form and email settings specs.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant FormController as Form Controller
    participant AuthModule as AuthorizedForForm
    participant SubmitValues as SubmitValues
    participant Inviter as Invitation<br/>Workflow
    participant Event as Event Tracker
    
    User->>FormController: Complete form submission
    FormController->>AuthModule: call(submitter, user, request)
    AuthModule->>AuthModule: pass_email_2fa?()
    AuthModule->>AuthModule: pass_link_2fa?()
    AuthModule-->>FormController: authorization result
    
    alt Authorized
        FormController->>SubmitValues: maybe_invite_via_field()
        SubmitValues->>Inviter: Create submitters from<br/>field mappings
        Inviter-->>SubmitValues: New submitters created
        SubmitValues->>Event: Track invitation events
        Event-->>SubmitValues: Logged
        FormController->>Event: Track form_complete
        Event-->>FormController: Logged
        FormController-->>User: Success response
    else Not Authorized
        FormController-->>User: Access denied
    end
Loading
sequenceDiagram
    actor User
    participant Editor as Markdown Editor
    participant TipTap as TipTap<br/>Instance
    participant Toolbar as Toolbar<br/>Actions
    participant Hidden as Hidden<br/>Textarea
    participant Form as Form<br/>Submit
    
    User->>Editor: Focus editor
    Editor->>TipTap: Initialize with extensions
    TipTap-->>Editor: Ready
    User->>Toolbar: Click bold button
    Toolbar->>TipTap: Execute bold command
    TipTap->>Editor: Apply formatting
    User->>Editor: Type or paste content
    Editor->>Editor: Update on-change
    Editor->>Hidden: Sync markdown to textarea
    User->>Form: Submit form
    Form->>Hidden: Read textarea value
    Hidden-->>Form: Markdown content
    Form-->>User: Submitted
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A markdown editor hops into view,
With bold, italic, links to pursue,
Authorization gates guard the way,
Field-based invites brighten the day,
SMS retries with exponential grace,
This code improvement wins the race! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.98% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: SMS error handling with timeouts, retry logic with exponential backoff, and Redis-backed rate limiting with fallback.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sms-error-handling-and-redis-rate-limit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dnplkndll
Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 28, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@dnplkndll
Copy link
Copy Markdown
Author

@cursor-review review

@dnplkndll
Copy link
Copy Markdown
Author

bugbot run

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/javascript/submission_form/completed.vue (1)

190-207: ⚠️ Potential issue | 🟠 Major

Prevent method override and handle all error cases in download().

At lines 191–192, ...this.fetchOptions will override method: 'GET' if fetchOptions contains a method key. Additionally, isDownloading is not reset in two scenarios: (1) when the fetch response is not OK (line 205), and (2) when a network error occurs.

🔧 Proposed fix
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`, {
-  method: 'GET',
-  ...this.fetchOptions
+  ...this.fetchOptions,
+  method: 'GET'
}).then(async (response) => {
  if (response.ok) {
    const urls = await response.json()
    const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
    const isSafariIos = isMobileSafariIos || /iPhone|iPad|iPod/i.test(navigator.userAgent)

    if (isSafariIos && urls.length > 1) {
      this.downloadSafariIos(urls)
    } else {
      this.downloadUrls(urls)
    }
  } else {
    alert(this.t('failed_to_download_files'))
+    this.isDownloading = false
  }
+}).catch(() => {
+  alert(this.t('failed_to_download_files'))
+  this.isDownloading = false
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/submission_form/completed.vue` around lines 190 - 207, The
fetch call can have its method overridden by this.fetchOptions and isDownloading
isn't reset on non-OK responses or network errors; update the fetch options so
the explicit method wins (e.g., merge with ...this.fetchOptions first and then
method: 'GET', or use Object.assign({}, this.fetchOptions, { method: 'GET' }))
and add error handling to reset this.isDownloading: set this.isDownloading =
false in the else branch where response.ok is false and add a .catch(...) (or a
.finally(...)) on the fetch promise to reset this.isDownloading on network
errors, keeping the existing calls to downloadSafariIos(urls) /
downloadUrls(urls) unchanged.
app/views/submissions/show.html.erb (1)

99-99: ⚠️ Potential issue | 🟡 Minor

Remove redundant keyword_init: true to fix pipeline failure.

Ruby 3.2+ includes keyword initialization by default for Struct.new.

🔧 Proposed fix
-<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
+<% page_blob_struct = Struct.new(:url, :metadata) %>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/submissions/show.html.erb` at line 99, The Struct declaration
page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) is failing
the pipeline because Ruby 3.2+ makes keyword_init redundant; remove the
keyword_init: true argument so the Struct is declared as Struct.new(:url,
:metadata) (keep the page_blob_struct variable name intact) to resolve the
failure.
app/views/submit_form/show.html.erb (1)

8-8: ⚠️ Potential issue | 🟡 Minor

Remove redundant keyword_init: true to fix pipeline failure.

Ruby 3.2+ includes keyword initialization by default for Struct.new, making the keyword_init: true option redundant.

🔧 Proposed fix
-<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
+<% page_blob_struct = Struct.new(:url, :metadata) %>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/submit_form/show.html.erb` at line 8, Remove the redundant
keyword_init argument when defining the Struct: update the page_blob_struct
declaration (the Struct.new call used to define page_blob_struct) to drop
keyword_init: true so it relies on Ruby 3.2+ default keyword initialization;
this removes the unnecessary option and fixes the pipeline failure.
🧹 Nitpick comments (15)
spec/system/signing_form_spec.rb (2)

645-645: Consider specifying the dropdown explicitly to avoid ambiguity.

Using select 'Approved' without a from: argument works when there's only one dropdown, but could become fragile if additional dropdowns are added to the form. Consider specifying the dropdown explicitly for clarity and maintainability.

♻️ Suggested improvement
-      select 'Approved'
+      select 'Approved', from: 'signing_reason' # or the appropriate label/id
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/system/signing_form_spec.rb` at line 645, The test currently calls the
Capybara helper select with just the value (select 'Approved'), which is
ambiguous; update that call to include an explicit from: argument referencing
the dropdown's label or name (e.g. the status/select field label or id used in
the form) so the selector targets the correct select element; locate the select
invocation in signing_form_spec.rb and change it to use select('Approved', from:
'<field label or id>') to make the spec robust and maintainable.

629-655: Test does not verify the signing reason was persisted.

The test selects 'Approved' as the signing reason but only asserts completed_at and Signature presence. Consider adding an assertion to verify the signing reason value was actually saved (e.g., checking for the reason in submitter.values, submission events, or relevant model attributes).

💡 Suggested addition
       expect(submitter.completed_at).to be_present
       expect(field_value(submitter, 'Signature')).to be_present
+      # Verify signing reason was saved - adjust based on where the reason is stored
+      expect(submitter.signing_reason).to eq('Approved')
     end

Note: Adjust the assertion based on how the signing reason is actually stored in your domain model.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/system/signing_form_spec.rb` around lines 629 - 655, Add an assertion
after submitter.reload to verify the chosen signing reason was persisted: locate
the test's submitter and use the existing helpers or model attributes (e.g.
field_value(submitter, 'Signing reason') or submitter.values /
submitter.form_values) to assert it equals 'Approved'; alternatively assert the
last submission event or audit record for the submission includes the 'Approved'
reason if your app stores reasons there (use symbols/fields present in the spec
like submitter, field_value, submission/events to find the correct place).
lib/templates/create_attachments.rb (1)

78-83: Consider using block form for Zip::File.open to ensure cleanup on exception.

With the new exception path at line 83, the Zip::File handle may not be properly closed when InvalidFileType is raised mid-iteration. Using the block form ensures the file is closed regardless of exceptions.

♻️ Suggested improvement
-          Zip::File.open(file.tempfile).each do |entry|
+          Zip::File.open(file.tempfile) do |zip_file|
+            zip_file.each do |entry|
             next if entry.directory?

             total_size += entry.size

             raise InvalidFileType, 'zip_too_large' if total_size > MAX_ZIP_SIZE

             tempfile = Tempfile.new(entry.name)
             tempfile.binmode
             entry.get_input_stream { |in_stream| IO.copy_stream(in_stream, tempfile) }
             tempfile.rewind

             type = Marcel::MimeType.for(tempfile, name: entry.name)

             next if type.exclude?('image') &&
                     type != PDF_CONTENT_TYPE &&
                     type != JSON_CONTENT_TYPE &&
                     DOCUMENT_CONTENT_TYPES.exclude?(type)

             extracted_files << ActionDispatch::Http::UploadedFile.new(
               filename: File.basename(entry.name),
               type:,
               tempfile:
             )
+            end
           end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/templates/create_attachments.rb` around lines 78 - 83, The Zip::File is
opened with Zip::File.open(file.tempfile) and may not be closed if
InvalidFileType is raised while iterating; change to the block form
Zip::File.open(file.tempfile) do |zip| ... end (or use Zip::File.open with a
block) so the archive is always closed on exception, keep the same logic that
skips directories, accumulates total_size and raises InvalidFileType with
'zip_too_large' when total_size > MAX_ZIP_SIZE; ensure you reference the same
variables (entry, total_size, MAX_ZIP_SIZE) inside the block.
app/javascript/elements/toggle_attribute.js (1)

6-7: Inconsistent type coercion in value/dataValue comparison.

The code normalizes dataset.value === 'false' to boolean false (line 7), but compares it against event.target.value from select elements, which return strings. While this works via JavaScript's loose comparison, it's inconsistent with line 17, which compares string values directly.

For clarity and consistency, normalize both 'true' and 'false' strings to their boolean equivalents:

♻️ Suggested improvement
-      const dataValue = this.dataset.value === 'false' ? false : this.dataset.value || true
+      const dataValue = this.dataset.value === 'false' ? false : (this.dataset.value === 'true' ? true : this.dataset.value)

This makes the intent explicit: string values like 'email' are compared as strings, while 'false'/'true' are converted to booleans for explicit conditional logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/elements/toggle_attribute.js` around lines 6 - 7, Normalize
string booleans consistently: update the logic that sets value and dataValue in
toggle_attribute.js so both handle the literal strings "true" and "false" the
same way (convert "true" -> true and "false" -> false), while leaving other
strings (e.g., "email") as-is; specifically adjust the assignment for value
(derived from event.target.value or checked) and dataValue (this.dataset.value)
so comparisons later in the function (referencing value and dataValue) are
comparing normalized types, or create a small helper (e.g., parseBooleanString)
and use it when setting value and dataValue.
lib/submitters.rb (1)

17-17: Consider using a distinct name for the custom exception.

Defining ArgumentError within the Submitters module shadows Ruby's built-in ArgumentError. While scoped correctly as Submitters::ArgumentError, this could cause confusion when debugging or when someone rescues ArgumentError expecting standard library behavior.

A more explicit name like MissingFileError or InvalidArgumentError would improve clarity.

💡 Suggested rename
-  ArgumentError = Class.new(StandardError)
+  MissingFileError = Class.new(StandardError)

And update the raise statement:

-        raise ArgumentError, 'file param is missing'
+        raise MissingFileError, 'file param is missing'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/submitters.rb` at line 17, Rename the custom exception constant currently
defined as ArgumentError within the Submitters module to a distinct name like
InvalidArgumentError (i.e. define Submitters::InvalidArgumentError instead of
Submitters::ArgumentError) and update every raise that refers to this custom
error to raise Submitters::InvalidArgumentError, plus update any local rescue
clauses that expect the module-local ArgumentError to use the new
InvalidArgumentError symbol so you no longer shadow Ruby's built-in
ArgumentError.
spec/lib/rate_limit_spec.rb (1)

5-42: Add explicit coverage for Redis fallback behavior.

Behavioral tests are solid, but this suite does not assert the new store-selection contract (Redis when available, MemoryStore fallback when REDIS_URL is missing/unavailable). A focused spec here would protect the main PR objective from regression.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/lib/rate_limit_spec.rb` around lines 5 - 42, Add specs that assert the
store-selection contract by testing RateLimit::STORE directly under both
Redis-present and Redis-absent/failing conditions: stub ENV['REDIS_URL'] (or
temporarily delete it) and/or stub the Redis client initializer used by
RateLimit to raise, then reload or reinitialize RateLimit and assert
described_class::STORE is an instance of MemoryStore; also add a complementary
spec that sets ENV['REDIS_URL'] to a fake URL and ensures described_class::STORE
is an instance of the Redis-backed store (e.g., RedisStore) so the code paths in
RateLimit.initialize/STORE selection and the .call behavior are covered.
app/javascript/submission_form/signature_step.vue (1)

793-801: Extract duplicated blocked-canvas alert/logging into one helper.

The new branch is good, but the same block now appears twice. Centralizing it will reduce drift and make future message/log updates safer.

♻️ Proposed refactor
+    notifyCanvasBlocked () {
+      if (isCanvasBlocked()) {
+        alert(this.t('browser_privacy_settings_block_canvas'))
+        if (window.Rollbar) window.Rollbar.info('Canvas blocked')
+        return true
+      }
+      return false
+    },

@@
-          if (isCanvasBlocked()) {
-            alert(this.t('browser_privacy_settings_block_canvas'))
-
-            if (window.Rollbar) {
-              window.Rollbar.info('Canvas blocked')
-            }
-          } else {
+          if (!this.notifyCanvasBlocked()) {
             alert(this.t('signature_is_too_small_or_simple_please_redraw'))
           }

@@
-            if (isCanvasBlocked()) {
-              alert(this.t('browser_privacy_settings_block_canvas'))
-
-              if (window.Rollbar) {
-                window.Rollbar.info('Canvas blocked')
-              }
-            } else {
+            if (!this.notifyCanvasBlocked()) {
               alert(this.t('signature_is_too_small_or_simple_please_redraw'))
             }

Also applies to: 857-865

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/submission_form/signature_step.vue` around lines 793 - 801,
Extract the duplicated "canvas blocked" alert/logging into a single helper
(e.g., create a method like showCanvasBlockedAlert or handleBlockedCanvas) and
replace both occurrences with calls to that helper; the helper should call
alert(this.t('browser_privacy_settings_block_canvas')) and, if window.Rollbar
exists, call window.Rollbar.info('Canvas blocked') so both the UI alert and
logging behavior from the existing isCanvasBlocked() branches are centralized
(update usages where isCanvasBlocked() is checked in the signature_step.vue
code, including the other occurrence around lines 857-865).
spec/jobs/send_submitter_invitation_sms_job_spec.rb (1)

114-124: Consider adding a test for Faraday::ConnectionFailed.

The job rescues Faraday::ConnectionFailed but only Faraday::TimeoutError (via to_timeout) is tested. Consider adding coverage for connection failures.

Example test for connection failure
context 'when connection fails' do
  before do
    stub_request(:post, twilio_url).to_raise(Faraday::ConnectionFailed.new('Connection refused'))
  end

  it 'schedules a retry' do
    expect {
      described_class.new.perform('submitter_id' => submitter.id)
    }.to change(described_class.jobs, :size).by(1)
  end
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spec/jobs/send_submitter_invitation_sms_job_spec.rb` around lines 114 - 124,
Add a new spec context that mirrors the existing timeout test but simulates a
Faraday::ConnectionFailed to cover the rescue path: stub the POST to twilio_url
to_raise(Faraday::ConnectionFailed.new(...)) and assert that calling
described_class.new.perform('submitter_id' => submitter.id) changes
described_class.jobs.size by 1; reference the existing context for "when network
timeout occurs", the twilio_url stub, and the perform invocation to model the
new "when connection fails" test.
lib/replace_email_variables.rb (1)

172-174: Consider using build_url_options_for for consistency.

build_submission_link still uses Docuseal.default_url_options directly while other link builders now use the new build_url_options_for helper. This could lead to inconsistent URLs if custom domains are configured.

Suggested change for consistency
 def build_submission_link(submission)
-  Rails.application.routes.url_helpers.submission_url(submission, **Docuseal.default_url_options)
+  submitter = submission.submitters.first
+  url_options = submitter ? build_url_options_for(submitter) : Docuseal.default_url_options
+  Rails.application.routes.url_helpers.submission_url(submission, **url_options)
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/replace_email_variables.rb` around lines 172 - 174, build_submission_link
currently calls submission_url with Docuseal.default_url_options; change it to
use the shared helper build_url_options_for to keep URL generation consistent
with other link builders. Locate the build_submission_link method and replace
the second argument so it passes build_url_options_for(submission) (or
build_url_options_for(object) as used elsewhere) instead of
Docuseal.default_url_options, ensuring submission_url still receives the same
submission object and that build_url_options_for is available in the scope.
lib/rate_limit.rb (1)

6-15: Consider logging when falling back to MemoryStore.

The silent fallback to MemoryStore when Redis initialization fails could mask configuration issues. In production, operators may not realize rate limiting isn't working across workers.

Add logging for fallback scenario
 STORE = begin
   redis_url = ENV.fetch('REDIS_URL', nil)
   if redis_url.present?
     ActiveSupport::Cache::RedisCacheStore.new(url: redis_url, namespace: 'rate_limit')
   else
     ActiveSupport::Cache::MemoryStore.new
   end
-rescue StandardError
+rescue StandardError => e
+  Rails.logger.warn("RateLimit: Failed to initialize Redis cache (#{e.class}: #{e.message}), falling back to MemoryStore")
   ActiveSupport::Cache::MemoryStore.new
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/rate_limit.rb` around lines 6 - 15, The STORE fallback silently swallows
Redis init failures; modify the block that creates
ActiveSupport::Cache::RedisCacheStore (symbol STORE and the RedisCacheStore.new
call) so that when initialization fails you rescue as `rescue StandardError =>
e` and log a clear warning/error via Rails.logger (including the redis_url and
exception message/stack as appropriate) before returning
ActiveSupport::Cache::MemoryStore.new; also add a short log when
ENV['REDIS_URL'] is blank to indicate MemoryStore is being used for rate
limiting in that case.
lib/markdown_to_html.rb (1)

78-130: Complex inline parser - consider adding inline documentation.

The parse_inline function handles multiple markdown constructs (code spans, links, emphasis) with a stateful approach. While functional, the logic is dense. Consider adding brief comments explaining the state machine behavior, particularly for the *** triple-asterisk handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/markdown_to_html.rb` around lines 78 - 130, The parse_inline method is
dense and needs brief inline comments to clarify its state-machine behavior: add
a short comment above the parse_inline definition explaining its purpose and
that it uses a context stack to track open inline tags; inside the tag lambda
describe what TAGS lookup returns and how is_end/context push/pop works; inside
the flush lambda note that it emits closing tags for all open context entries;
add comments around the INLINE_TOKENIZER match loop to explain handling of code
spans (m[4]), link starts/ends (m[1], m[2], m[3]), and emphasis tokens (m[5]),
and explicitly document the special-case logic for the '***' branch (why it
prefers ordering of '*' vs '**' based on current context). Reference symbols:
parse_inline, tag lambda, flush lambda, INLINE_TOKENIZER, TAGS, context, and the
'***' handling branch.
app/javascript/template_builder/i18n.js (1)

196-196: Good typo fix, but consider renaming the key for consistency.

The text correction from "create an send" to "create and send" is correct. However, the key name still contains the typo (...create_an_send...). Consider renaming the key to start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document for consistency, though this would require updating all references.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/template_builder/i18n.js` at line 196, The i18n key
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document still
contains the typo; rename it to
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document in the
locale file and update every code reference that reads this key (search for
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document) so
callers (components, tests, templates) use the new key; run the app/tests to
ensure no missing translation errors and remove the old key entry after all
references are updated.
lib/submissions/generate_audit_trail.rb (1)

494-498: Consider applying with_timestamp_seconds to document timestamps as well.

The time_format selection based on with_timestamp_seconds is applied only to event log timestamps here (line 497). However, the document "Generated at" timestamps at lines 231-232 still use the hardcoded :long format. If the intent is consistent timestamp precision across the audit trail, consider applying the same format there.

💡 Optional: Apply consistent timestamp format to document timestamps
              { text: "#{I18n.t('generated_at')}: ", font: [FONT_NAME, { variant: :bold }] },
-              "#{I18n.l(document.created_at.in_time_zone(timezone), format: :long, locale: account.locale)} " \
+              "#{I18n.l(document.created_at.in_time_zone(timezone), format: time_format, locale: account.locale)} " \
              "#{TimeUtils.timezone_abbr(timezone, document.created_at)}"

Note: This would require computing time_format earlier in build_audit_trail, before the documents_data block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/submissions/generate_audit_trail.rb` around lines 494 - 498, Compute
time_format earlier in build_audit_trail using with_timestamp_seconds (e.g.
time_format = with_timestamp_seconds ? :detailed : :long) so it’s available to
the documents_data block, then replace the hardcoded :long used for the document
"Generated at" timestamps with that time_format and format the document
timestamp with I18n.l(document.generated_at.in_time_zone(timezone), format:
time_format, locale: account.locale) and include
TimeUtils.timezone_abbr(timezone, document.generated_at) to ensure consistent
precision and timezone annotation across both event and document timestamps.
app/views/templates_preferences/_submitter_invitation_email_form.html.erb (1)

52-52: Verify array element assumptions for subject/body extraction.

Using .first for subject and .last for body assumes the values_at call returns elements in the expected order. If default_template_email_preferences_values has a different structure (e.g., only one element), the subject could incorrectly get the body value. Consider using explicit named access instead.

💡 Alternative using explicit named access
-<% submitter_email_values = submitter_email_preferences_values || template_email_preferences_values.presence || default_template_email_preferences_values %>
+<% submitter_email_subject = submitter_preferences['request_email_subject'].presence || 
+                              f.object.preferences['request_email_subject'].presence || 
+                              default_template_email_preferences_values.first %>
+<% submitter_email_body = submitter_preferences['request_email_body'].presence || 
+                           f.object.preferences['request_email_body'].presence || 
+                           default_template_email_preferences_values.last %>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/templates_preferences/_submitter_invitation_email_form.html.erb` at
line 52, The code assigns submitter_email_values and later uses .first/.last to
get subject/body which is brittle; change the extraction to explicitly pull
named elements instead of relying on order: after computing
submitter_email_values (from submitter_email_preferences_values,
template_email_preferences_values, or default_template_email_preferences_values)
extract subject and body explicitly (e.g., submitter_subject =
submitter_email_values[0] || "" and submitter_body = submitter_email_values[1]
|| "" if the value is an array, or convert submitter_email_values to a hash and
use submitter_email_values[:subject] and [:body] if it can be a hash) and add
safe defaults — update references to submitter_subject and submitter_body
wherever .first/.last were used.
app/views/personalization_settings/_markdown_editor.html.erb (1)

16-48: Consider using platform-aware keyboard shortcut hints.

The tooltips show Ctrl+ shortcuts which are Windows/Linux conventions. Mac users typically expect Cmd+ (⌘). Consider detecting the platform and adjusting hints accordingly, or using a generic format like Ctrl/⌘+B.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/personalization_settings/_markdown_editor.html.erb` around lines 16
- 48, The tooltip hardcodes "Ctrl+" shortcuts in the markdown toolbar; update so
shortcuts are platform-aware by generating or injecting a Cmd/Ctrl-aware string
instead of literal "Ctrl+". Either (A) add a view helper (e.g.,
platform_shortcut_hint(key) used in the data-tip attributes for each
button—t('bold') + " (" + platform_shortcut_hint('B') + ")") or (B) have the
markdown-editor Stimulus controller detect navigator.platform on connect and
replace the data-tip strings for the buttons with the correct prefix (use the
existing data-action/data-target attributes like markdown-editor#bold,
markdown-editor.italicButton, markdown-editor.linkButton,
markdown-editor.undoButton, markdown-editor.redoButton to find elements). Ensure
the change updates all occurrences of t('...') tooltip text
(bold/italic/underline/link/undo/redo) to show "Ctrl/⌘+X" or the correct
platform-specific symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/javascript/elements/markdown_editor.js`:
- Around line 378-383: disconnectedCallback currently assumes this.linkTooltip
and this.editor exist and can throw if initialization in connectedCallback was
skipped or failed; guard accesses by checking existence before calling hide() or
destroy(): in disconnectedCallback, only call this.linkTooltip.hide() if
this.linkTooltip is truthy (and optionally has hide method) and only call
this.editor.destroy() if this.editor is truthy (and optionally has destroy
method), so teardown is no-op when initialization didn't complete.
- Around line 149-153: The fallback textarea is hidden before the async
loadTiptap() completes, leaving the form without an input if the import fails or
the element disconnects; change the flow in the initialization that calls
this.adjustShortcutsForPlatform() and const { Editor, ... } = await loadTiptap()
so you await and try/catch loadTiptap() first, verify this.isConnected (or
similar mount flag) after the await, and only then set
this.textarea.style.display = 'none' and proceed to instantiate
Editor/Extensions; on error (or if disconnected) keep the textarea visible, log
the failure, and abort editor creation to ensure the fallback remains usable.

In `@app/javascript/elements/toggle_attribute.js`:
- Around line 16-18: The conditional uses event.target.value directly which is
incorrect for checkboxes; update the branch inside the if
(this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') block
to use the already-computed value variable (from earlier in the function)
instead of event.target.value so the disabled toggle reflects the computed
checked state; locate the logic in the toggle handler in toggle_attribute.js
(the function that computes value and then sets this.target.disabled) and
replace the event.target.value usage with value.

In `@app/javascript/elements/toggle_classes.js`:
- Around line 3-15: The click handler assumes a valid trigger and target; add
null guards so it doesn't throw: verify that the computed trigger element
(button) exists before calling button.addEventListener and bail early if
missing, ensure this.dataset.classes is present and non-empty before splitting,
and check that the resolved target (the variable target from
this.dataset.targetId or fallback to button) is non-null before attempting
target.classList.remove/add/toggle; update the logic around the variables
"button" and "target" in toggle_classes.js to perform these checks and return
early or skip class operations when they are absent.

In `@app/javascript/template_builder/formula_modal.vue`:
- Around line 185-187: normalizeFormula currently resolves referenced names
against this.template.fields which bypasses the filtering rules in fields();
change the lookup to use the filtered list returned by the fields() helper
(e.g., call this.fields().find(...)) so manually-typed self-references, circular
or non-numeric dependencies are excluded; keep the same comparison logic (use
buildDefaultName(f) and trim()) but operate on this.fields() instead of
this.template.fields to restore insertion safety.
- Around line 169-171: isNumberField currently treats strings like "--" or
"1.2.3" as numeric because the regex `/^[\d.-]+$/` is too permissive; update the
validation inside isNumberField (referencing field.type, field.options, and
option value o.value) to use a stricter numeric check — either replace the regex
with one that allows an optional sign and at most one decimal point (e.g.
/^[+-]?(?:\d+(\.\d+)?|\.\d+)$/) or validate via Number parsing combined with a
string-match to ensure no extra characters (e.g. Number.isFinite(Number(val))
plus confirming the original string matches the canonical numeric form). Ensure
the change is applied where options?.every((o) => ...) is used so only truly
numeric option values are treated as numbers.

In `@app/jobs/send_test_webhook_request_job.rb`:
- Line 29: The test-webhook validation currently always requires HTTPS (the
raise HttpsError line that checks uri.scheme and port) but must honor the same
account override used by SendWebhookRequest.call; update the condition in
SendTestWebhookRequestJob to consult the account's allow_http flag (or the same
helper used by SendWebhookRequest.call) so that if allow_http is true HTTP URIs
are permitted, otherwise enforce uri.scheme == 'https' and port 443 as before;
keep the raise HttpsError but change its predicate to mirror
SendWebhookRequest.call's logic.

In `@app/mailers/submitter_mailer.rb`:
- Around line 265-269: Add a CUSTOM_DOMAIN constant to the AccountConfig class
(e.g., CUSTOM_DOMAIN = 'custom_domain') and replace the symbol usage with that
constant wherever the key is looked up; specifically update the
maybe_set_custom_domain method to use key: AccountConfig::CUSTOM_DOMAIN and
update the usage in ReplaceEmailVariables (method in
lib/replace_email_variables.rb that references the key at the noted spot) to use
AccountConfig::CUSTOM_DOMAIN so key lookups are consistent with existing
constants like BCC_EMAILS.

In `@app/views/email_smtp_settings/index.html.erb`:
- Line 25: The required flag for the SMTP password field is inverted: in the
password input helper ff.password_field :password the required currently uses
value['password'].present? but should require input when the stored password is
missing; change the condition to use value['password'].blank? (or
!value['password'].present?) so new setups require a password and existing
setups do not force re-entry.

In `@app/views/storage_settings/_aws_form.html.erb`:
- Line 11: The secret_access_key field is always marked required which forces
users to re-enter the key on every edit; change the required flag on the
password field (the call to fff.password_field for :secret_access_key) to be
conditional so it is only required when there is no stored key (e.g. use
required: configs['secret_access_key'].blank?), preserving the placeholder
hiding when a key exists.

In `@app/views/storage_settings/_azure_form.html.erb`:
- Line 16: The password field for storage_access_key is always marked required
which forces re-entry even when an existing key is present; change the required
option on the fff.password_field :storage_access_key call to be conditional
(e.g., required only when configs['storage_access_key'] is blank) so users can
leave the masked placeholder and not have to re-enter the key on every update
while keeping the existing masked placeholder behavior.

In `@app/views/storage_settings/_google_cloud_form.html.erb`:
- Line 16: The credentials textarea currently forces re-entry by always using
required: true; update the fff.text_area for :credentials so the required option
is conditional based on whether configs['credentials'] exists (e.g. set
required: !configs['credentials'].present?). Keep the redacted placeholder
behavior (configs['credentials'].present? ? "{\n**REDACTED**\n}" : '') and only
make the field required when there are no existing credentials, referencing the
same :credentials field helper to locate the control.

In `@config/dotenv.rb`:
- Line 63: The privilege check uses Process.uid.zero? which tests the real UID
and can miss setuid/effective-root cases; update the condition to use
Process.euid.zero? so the code that gates on root privileges runs based on the
effective UID (replace the usage of Process.uid.zero? in the privilege-gated
block with Process.euid.zero? in the same code path).

In `@lib/download_utils.rb`:
- Around line 38-45: The download utility's call method currently defaults the
validate parameter to Docuseal.multitenant?, which disables URI validation in
single-tenant setups and weakens SSRF protections; change the default to
validate: false in the call method signature and any other affected methods
(same pattern at lines ~60-64), keep calling validate_uri! when validate is
true, and then update every caller that requires validation to explicitly pass
validate: true (or only enable validate at trusted, audited call sites) so
validation is secure-by-default; reference: the call method, validate parameter,
validate_uri!, and Docuseal.multitenant?.

In `@lib/submissions/generate_result_attachments.rb`:
- Around line 327-336: The code sets time_format = with_timestamp_seconds ?
:detailed : :long and then calls I18n.l(..., format: time_format), which will
raise missing translation errors for locales that lack time.formats.detailed;
update the affected locale files to add a time.formats.detailed entry for ar,
cs, he, ja, ko, pl, uk (matching the desired detailed format), or change the
code to use an existing common key (e.g., :long) as a safe fallback when
I18n.exists?("time.formats.#{time_format}", locale) is false before calling
I18n.l; modify the logic around with_timestamp_seconds/time_format or perform an
existence check so I18n.l never requests a missing :detailed format.

In `@lib/submitters/authorized_for_form.rb`:
- Around line 36-39: The code builds link_2fa_key using submitter.email without
guarding for nil, causing a 500 for phone-only submitters; update the two-factor
check in authorized_for_form (the block that reads
request.params[:two_factor_token] and builds link_2fa_key) to first return false
if submitter.email.blank? (or otherwise handle nil) before constructing
link_2fa_key and verifying with Submitter.signed_id_verifier.verified(token,
purpose: :email_two_factor) so missing emails produce a safe false result
instead of raising.

In `@lib/submitters/submit_values.rb`:
- Line 9: The PHONE_REGEXP is too permissive and unanchored (PHONE_REGEXP),
causing substrings to match; change it to an anchored pattern that enforces a
minimum number of digits and full-string matching (e.g. use \A and \z and a
digit-count requirement such as a lookahead like (?=(?:.*\d){7,}) or require at
least 7 digit characters) and include only allowed separators, then replace the
current /[+\d()\s-]+/ with that anchored, stricter regex so phone validation
only matches entire valid phone strings wherever PHONE_REGEXP is used.
- Around line 420-445: The locals email and phone can leak values between
iterations of the submission.template_submitters loop; reinitialize email and
phone to nil at the top of the loop (just after obtaining field_uuid or before
checking value) so each invite candidate starts with a fresh contact; ensure the
existing logic using PHONE_REGEXP, Submissions.normalize_email, and
submission.submitters.create! uses these reinitialized variables to avoid
cross-recipient leakage.
- Around line 456-458: In validate_value! ensure you guard against a nil/unknown
field before accessing field['readonly'] and field['uuid']; if field is nil
(meaning the submitted UUID wasn't found in template_fields) raise or return the
controlled validation error instead of touching field['readonly'], and keep the
Rollbar.warning call only after confirming field is present (reference
validate_value!, field['readonly'], and field['uuid'] to locate the checks).

---

Outside diff comments:
In `@app/javascript/submission_form/completed.vue`:
- Around line 190-207: The fetch call can have its method overridden by
this.fetchOptions and isDownloading isn't reset on non-OK responses or network
errors; update the fetch options so the explicit method wins (e.g., merge with
...this.fetchOptions first and then method: 'GET', or use Object.assign({},
this.fetchOptions, { method: 'GET' })) and add error handling to reset
this.isDownloading: set this.isDownloading = false in the else branch where
response.ok is false and add a .catch(...) (or a .finally(...)) on the fetch
promise to reset this.isDownloading on network errors, keeping the existing
calls to downloadSafariIos(urls) / downloadUrls(urls) unchanged.

In `@app/views/submissions/show.html.erb`:
- Line 99: The Struct declaration page_blob_struct = Struct.new(:url, :metadata,
keyword_init: true) is failing the pipeline because Ruby 3.2+ makes keyword_init
redundant; remove the keyword_init: true argument so the Struct is declared as
Struct.new(:url, :metadata) (keep the page_blob_struct variable name intact) to
resolve the failure.

In `@app/views/submit_form/show.html.erb`:
- Line 8: Remove the redundant keyword_init argument when defining the Struct:
update the page_blob_struct declaration (the Struct.new call used to define
page_blob_struct) to drop keyword_init: true so it relies on Ruby 3.2+ default
keyword initialization; this removes the unnecessary option and fixes the
pipeline failure.

---

Nitpick comments:
In `@app/javascript/elements/toggle_attribute.js`:
- Around line 6-7: Normalize string booleans consistently: update the logic that
sets value and dataValue in toggle_attribute.js so both handle the literal
strings "true" and "false" the same way (convert "true" -> true and "false" ->
false), while leaving other strings (e.g., "email") as-is; specifically adjust
the assignment for value (derived from event.target.value or checked) and
dataValue (this.dataset.value) so comparisons later in the function (referencing
value and dataValue) are comparing normalized types, or create a small helper
(e.g., parseBooleanString) and use it when setting value and dataValue.

In `@app/javascript/submission_form/signature_step.vue`:
- Around line 793-801: Extract the duplicated "canvas blocked" alert/logging
into a single helper (e.g., create a method like showCanvasBlockedAlert or
handleBlockedCanvas) and replace both occurrences with calls to that helper; the
helper should call alert(this.t('browser_privacy_settings_block_canvas')) and,
if window.Rollbar exists, call window.Rollbar.info('Canvas blocked') so both the
UI alert and logging behavior from the existing isCanvasBlocked() branches are
centralized (update usages where isCanvasBlocked() is checked in the
signature_step.vue code, including the other occurrence around lines 857-865).

In `@app/javascript/template_builder/i18n.js`:
- Line 196: The i18n key
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document still
contains the typo; rename it to
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document in the
locale file and update every code reference that reads this key (search for
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document) so
callers (components, tests, templates) use the new key; run the app/tests to
ensure no missing translation errors and remove the old key entry after all
references are updated.

In `@app/views/personalization_settings/_markdown_editor.html.erb`:
- Around line 16-48: The tooltip hardcodes "Ctrl+" shortcuts in the markdown
toolbar; update so shortcuts are platform-aware by generating or injecting a
Cmd/Ctrl-aware string instead of literal "Ctrl+". Either (A) add a view helper
(e.g., platform_shortcut_hint(key) used in the data-tip attributes for each
button—t('bold') + " (" + platform_shortcut_hint('B') + ")") or (B) have the
markdown-editor Stimulus controller detect navigator.platform on connect and
replace the data-tip strings for the buttons with the correct prefix (use the
existing data-action/data-target attributes like markdown-editor#bold,
markdown-editor.italicButton, markdown-editor.linkButton,
markdown-editor.undoButton, markdown-editor.redoButton to find elements). Ensure
the change updates all occurrences of t('...') tooltip text
(bold/italic/underline/link/undo/redo) to show "Ctrl/⌘+X" or the correct
platform-specific symbol.

In `@app/views/templates_preferences/_submitter_invitation_email_form.html.erb`:
- Line 52: The code assigns submitter_email_values and later uses .first/.last
to get subject/body which is brittle; change the extraction to explicitly pull
named elements instead of relying on order: after computing
submitter_email_values (from submitter_email_preferences_values,
template_email_preferences_values, or default_template_email_preferences_values)
extract subject and body explicitly (e.g., submitter_subject =
submitter_email_values[0] || "" and submitter_body = submitter_email_values[1]
|| "" if the value is an array, or convert submitter_email_values to a hash and
use submitter_email_values[:subject] and [:body] if it can be a hash) and add
safe defaults — update references to submitter_subject and submitter_body
wherever .first/.last were used.

In `@lib/markdown_to_html.rb`:
- Around line 78-130: The parse_inline method is dense and needs brief inline
comments to clarify its state-machine behavior: add a short comment above the
parse_inline definition explaining its purpose and that it uses a context stack
to track open inline tags; inside the tag lambda describe what TAGS lookup
returns and how is_end/context push/pop works; inside the flush lambda note that
it emits closing tags for all open context entries; add comments around the
INLINE_TOKENIZER match loop to explain handling of code spans (m[4]), link
starts/ends (m[1], m[2], m[3]), and emphasis tokens (m[5]), and explicitly
document the special-case logic for the '***' branch (why it prefers ordering of
'*' vs '**' based on current context). Reference symbols: parse_inline, tag
lambda, flush lambda, INLINE_TOKENIZER, TAGS, context, and the '***' handling
branch.

In `@lib/rate_limit.rb`:
- Around line 6-15: The STORE fallback silently swallows Redis init failures;
modify the block that creates ActiveSupport::Cache::RedisCacheStore (symbol
STORE and the RedisCacheStore.new call) so that when initialization fails you
rescue as `rescue StandardError => e` and log a clear warning/error via
Rails.logger (including the redis_url and exception message/stack as
appropriate) before returning ActiveSupport::Cache::MemoryStore.new; also add a
short log when ENV['REDIS_URL'] is blank to indicate MemoryStore is being used
for rate limiting in that case.

In `@lib/replace_email_variables.rb`:
- Around line 172-174: build_submission_link currently calls submission_url with
Docuseal.default_url_options; change it to use the shared helper
build_url_options_for to keep URL generation consistent with other link
builders. Locate the build_submission_link method and replace the second
argument so it passes build_url_options_for(submission) (or
build_url_options_for(object) as used elsewhere) instead of
Docuseal.default_url_options, ensuring submission_url still receives the same
submission object and that build_url_options_for is available in the scope.

In `@lib/submissions/generate_audit_trail.rb`:
- Around line 494-498: Compute time_format earlier in build_audit_trail using
with_timestamp_seconds (e.g. time_format = with_timestamp_seconds ? :detailed :
:long) so it’s available to the documents_data block, then replace the hardcoded
:long used for the document "Generated at" timestamps with that time_format and
format the document timestamp with
I18n.l(document.generated_at.in_time_zone(timezone), format: time_format,
locale: account.locale) and include TimeUtils.timezone_abbr(timezone,
document.generated_at) to ensure consistent precision and timezone annotation
across both event and document timestamps.

In `@lib/submitters.rb`:
- Line 17: Rename the custom exception constant currently defined as
ArgumentError within the Submitters module to a distinct name like
InvalidArgumentError (i.e. define Submitters::InvalidArgumentError instead of
Submitters::ArgumentError) and update every raise that refers to this custom
error to raise Submitters::InvalidArgumentError, plus update any local rescue
clauses that expect the module-local ArgumentError to use the new
InvalidArgumentError symbol so you no longer shadow Ruby's built-in
ArgumentError.

In `@lib/templates/create_attachments.rb`:
- Around line 78-83: The Zip::File is opened with Zip::File.open(file.tempfile)
and may not be closed if InvalidFileType is raised while iterating; change to
the block form Zip::File.open(file.tempfile) do |zip| ... end (or use
Zip::File.open with a block) so the archive is always closed on exception, keep
the same logic that skips directories, accumulates total_size and raises
InvalidFileType with 'zip_too_large' when total_size > MAX_ZIP_SIZE; ensure you
reference the same variables (entry, total_size, MAX_ZIP_SIZE) inside the block.

In `@spec/jobs/send_submitter_invitation_sms_job_spec.rb`:
- Around line 114-124: Add a new spec context that mirrors the existing timeout
test but simulates a Faraday::ConnectionFailed to cover the rescue path: stub
the POST to twilio_url to_raise(Faraday::ConnectionFailed.new(...)) and assert
that calling described_class.new.perform('submitter_id' => submitter.id) changes
described_class.jobs.size by 1; reference the existing context for "when network
timeout occurs", the twilio_url stub, and the perform invocation to model the
new "when connection fails" test.

In `@spec/lib/rate_limit_spec.rb`:
- Around line 5-42: Add specs that assert the store-selection contract by
testing RateLimit::STORE directly under both Redis-present and
Redis-absent/failing conditions: stub ENV['REDIS_URL'] (or temporarily delete
it) and/or stub the Redis client initializer used by RateLimit to raise, then
reload or reinitialize RateLimit and assert described_class::STORE is an
instance of MemoryStore; also add a complementary spec that sets
ENV['REDIS_URL'] to a fake URL and ensures described_class::STORE is an instance
of the Redis-backed store (e.g., RedisStore) so the code paths in
RateLimit.initialize/STORE selection and the .call behavior are covered.

In `@spec/system/signing_form_spec.rb`:
- Line 645: The test currently calls the Capybara helper select with just the
value (select 'Approved'), which is ambiguous; update that call to include an
explicit from: argument referencing the dropdown's label or name (e.g. the
status/select field label or id used in the form) so the selector targets the
correct select element; locate the select invocation in signing_form_spec.rb and
change it to use select('Approved', from: '<field label or id>') to make the
spec robust and maintainable.
- Around line 629-655: Add an assertion after submitter.reload to verify the
chosen signing reason was persisted: locate the test's submitter and use the
existing helpers or model attributes (e.g. field_value(submitter, 'Signing
reason') or submitter.values / submitter.form_values) to assert it equals
'Approved'; alternatively assert the last submission event or audit record for
the submission includes the 'Approved' reason if your app stores reasons there
(use symbols/fields present in the spec like submitter, field_value,
submission/events to find the correct place).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fbd7cba and a148f47.

⛔ Files ignored due to path filters (10)
  • Gemfile.lock is excluded by !**/*.lock
  • public/apple-icon-180x180.png is excluded by !**/*.png
  • public/apple-touch-icon-precomposed.png is excluded by !**/*.png
  • public/apple-touch-icon.png is excluded by !**/*.png
  • public/favicon-16x16.png is excluded by !**/*.png
  • public/favicon-32x32.png is excluded by !**/*.png
  • public/favicon-96x96.png is excluded by !**/*.png
  • public/favicon.ico is excluded by !**/*.ico
  • public/favicon.svg is excluded by !**/*.svg
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (117)
  • .rubocop.yml
  • Dockerfile
  • Gemfile
  • app/controllers/api/submissions_controller.rb
  • app/controllers/api/submitters_controller.rb
  • app/controllers/api/templates_controller.rb
  • app/controllers/submissions_download_controller.rb
  • app/controllers/submit_form_controller.rb
  • app/controllers/submit_form_decline_controller.rb
  • app/controllers/submit_form_download_controller.rb
  • app/controllers/submit_form_draw_signature_controller.rb
  • app/controllers/submit_form_invite_controller.rb
  • app/controllers/submit_form_values_controller.rb
  • app/controllers/template_documents_controller.rb
  • app/controllers/templates_controller.rb
  • app/controllers/templates_recipients_controller.rb
  • app/controllers/templates_uploads_controller.rb
  • app/javascript/application.js
  • app/javascript/application.scss
  • app/javascript/elements/markdown_editor.js
  • app/javascript/elements/toggle_attribute.js
  • app/javascript/elements/toggle_classes.js
  • app/javascript/submission_form/completed.vue
  • app/javascript/submission_form/form.vue
  • app/javascript/submission_form/i18n.js
  • app/javascript/submission_form/initials_step.vue
  • app/javascript/submission_form/invite_form.vue
  • app/javascript/submission_form/signature_step.vue
  • app/javascript/submission_form/validate_signature.js
  • app/javascript/template_builder/builder.vue
  • app/javascript/template_builder/formula_modal.vue
  • app/javascript/template_builder/i18n.js
  • app/javascript/template_builder/page.vue
  • app/jobs/send_submitter_invitation_sms_job.rb
  • app/jobs/send_test_webhook_request_job.rb
  • app/mailers/submitter_mailer.rb
  • app/models/account_config.rb
  • app/views/email_smtp_settings/index.html.erb
  • app/views/icons/_arrow_back_up.html.erb
  • app/views/icons/_arrow_forward_up.html.erb
  • app/views/icons/_bold.html.erb
  • app/views/icons/_italic.html.erb
  • app/views/icons/_underline.html.erb
  • app/views/layouts/application.html.erb
  • app/views/personalization_settings/_email_body_field.html.erb
  • app/views/personalization_settings/_markdown_editor.html.erb
  • app/views/personalization_settings/_submitter_completed_email_form.html.erb
  • app/views/pwa/manifest.json.erb
  • app/views/shared/_meta.html.erb
  • app/views/shared/_search_input.html.erb
  • app/views/start_form/_policy.html.erb
  • app/views/storage_settings/_aws_form.html.erb
  • app/views/storage_settings/_azure_form.html.erb
  • app/views/storage_settings/_google_cloud_form.html.erb
  • app/views/submissions/_detailed_form.html.erb
  • app/views/submissions/_email_form.html.erb
  • app/views/submissions/_phone_form.html.erb
  • app/views/submissions/_send_email_base.html.erb
  • app/views/submissions/_value.html.erb
  • app/views/submissions/show.html.erb
  • app/views/submissions_dashboard/index.html.erb
  • app/views/submissions_filters/_applied_filters.html.erb
  • app/views/submissions_filters/_filter_modal.html.erb
  • app/views/submit_form/show.html.erb
  • app/views/submitter_mailer/_custom_content.html.erb
  • app/views/submitter_mailer/documents_copy_email.html.erb
  • app/views/submitter_mailer/invitation_email.html.erb
  • app/views/templates/_submission.html.erb
  • app/views/templates/show.html.erb
  • app/views/templates_preferences/_recipients.html.erb
  • app/views/templates_preferences/_submitter_completed_email_form.html.erb
  • app/views/templates_preferences/_submitter_documents_copy_email_form.html.erb
  • app/views/templates_preferences/_submitter_invitation_email_form.html.erb
  • app/views/templates_prefillable_fields/_form.html.erb
  • app/views/templates_share_link/show.html.erb
  • app/views/webhook_settings/show.html.erb
  • config/dotenv.rb
  • config/initializers/active_storage.rb
  • config/locales/i18n.yml
  • config/routes.rb
  • lib/download_utils.rb
  • lib/load_active_storage_configs.rb
  • lib/markdown_to_html.rb
  • lib/rate_limit.rb
  • lib/replace_email_variables.rb
  • lib/send_sms.rb
  • lib/send_webhook_request.rb
  • lib/submissions.rb
  • lib/submissions/create_from_submitters.rb
  • lib/submissions/generate_audit_trail.rb
  • lib/submissions/generate_preview_attachments.rb
  • lib/submissions/generate_result_attachments.rb
  • lib/submitters.rb
  • lib/submitters/authorized_for_form.rb
  • lib/submitters/form_configs.rb
  • lib/submitters/normalize_values.rb
  • lib/submitters/submit_values.rb
  • lib/template_folders.rb
  • lib/templates.rb
  • lib/templates/clone.rb
  • lib/templates/create_attachments.rb
  • package.json
  • spec/jobs/send_form_completed_webhook_request_job_spec.rb
  • spec/jobs/send_form_declined_webhook_request_job_spec.rb
  • spec/jobs/send_form_started_webhook_request_job_spec.rb
  • spec/jobs/send_form_viewed_webhook_request_job_spec.rb
  • spec/jobs/send_submission_completed_webhook_request_job_spec.rb
  • spec/jobs/send_submission_created_webhook_request_job_spec.rb
  • spec/jobs/send_submission_expired_webhook_request_job_spec.rb
  • spec/jobs/send_submitter_invitation_sms_job_spec.rb
  • spec/jobs/send_template_created_webhook_request_job_spec.rb
  • spec/jobs/send_template_updated_webhook_request_job_spec.rb
  • spec/lib/rate_limit_spec.rb
  • spec/lib/send_sms_spec.rb
  • spec/rails_helper.rb
  • spec/system/email_settings_spec.rb
  • spec/system/signing_form_spec.rb
💤 Files with no reviewable changes (2)
  • spec/system/email_settings_spec.rb
  • Gemfile

Comment on lines +149 to +153
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()

const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid hiding the fallback textarea before async editor initialization succeeds.

If dynamic imports fail (or the element disconnects during await), the editor never mounts and the form is left without an editable input.

🔧 Suggested hardening
 async connectedCallback () {
   if (!this.textarea || !this.editorElement) return

-  this.textarea.style.display = 'none'
   this.adjustShortcutsForPlatform()

-  const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
+  let tiptap
+  try {
+    tiptap = await loadTiptap()
+  } catch (_error) {
+    return
+  }
+  if (!this.isConnected) return
+
+  this.textarea.style.display = 'none'
+  const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = tiptap
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = await loadTiptap()
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.adjustShortcutsForPlatform()
let tiptap
try {
tiptap = await loadTiptap()
} catch (_error) {
return
}
if (!this.isConnected) return
this.textarea.style.display = 'none'
const { Editor, Extension, Bold, Italic, Paragraph, Text, HardBreak, UndoRedo, Document, Link, Underline, Markdown, Plugin, Decoration, DecorationSet } = tiptap
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/elements/markdown_editor.js` around lines 149 - 153, The
fallback textarea is hidden before the async loadTiptap() completes, leaving the
form without an input if the import fails or the element disconnects; change the
flow in the initialization that calls this.adjustShortcutsForPlatform() and
const { Editor, ... } = await loadTiptap() so you await and try/catch
loadTiptap() first, verify this.isConnected (or similar mount flag) after the
await, and only then set this.textarea.style.display = 'none' and proceed to
instantiate Editor/Extensions; on error (or if disconnected) keep the textarea
visible, log the failure, and abort editor creation to ensure the fallback
remains usable.

Comment on lines +378 to +383
disconnectedCallback () {
this.linkTooltip.hide()

if (this.editor) {
this.editor.destroy()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard teardown when initialization is skipped or fails.

disconnectedCallback unconditionally accesses this.linkTooltip; this can throw when connectedCallback exited early.

🛠️ Minimal safe fix
   disconnectedCallback () {
-    this.linkTooltip.hide()
+    this.linkTooltip?.hide()

     if (this.editor) {
       this.editor.destroy()
+      this.editor = null
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/elements/markdown_editor.js` around lines 378 - 383,
disconnectedCallback currently assumes this.linkTooltip and this.editor exist
and can throw if initialization in connectedCallback was skipped or failed;
guard accesses by checking existence before calling hide() or destroy(): in
disconnectedCallback, only call this.linkTooltip.hide() if this.linkTooltip is
truthy (and optionally has hide method) and only call this.editor.destroy() if
this.editor is truthy (and optionally has destroy method), so teardown is no-op
when initialization didn't complete.

Comment on lines 16 to 18
if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
this.target.disabled = event.target.value !== this.dataset.value
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent use of event.target.value instead of computed value.

Line 17 uses event.target.value directly, while the rest of the refactored code uses the computed value variable (Line 6). For checkboxes, event.target.value returns the HTML value attribute (typically "on"), not the checked state, causing this condition to behave incorrectly.

🐛 Proposed fix
         if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
-          this.target.disabled = event.target.value !== this.dataset.value
+          this.target.disabled = value !== dataValue
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/elements/toggle_attribute.js` around lines 16 - 18, The
conditional uses event.target.value directly which is incorrect for checkboxes;
update the branch inside the if (this.dataset.className === 'hidden' &&
this.target.tagName === 'INPUT') block to use the already-computed value
variable (from earlier in the function) instead of event.target.value so the
disabled toggle reflects the computed checked state; locate the logic in the
toggle handler in toggle_attribute.js (the function that computes value and then
sets this.target.disabled) and replace the event.target.value usage with value.

Comment on lines +3 to +15
const button = this.querySelector('a, button, label')

const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button

button.addEventListener('click', () => {
this.dataset.classes.split(' ').forEach((cls) => {
button.classList.toggle(cls)
if (this.dataset.action === 'remove') {
target.classList.remove(cls)
} else if (this.dataset.action === 'add') {
target.classList.add(cls)
} else {
target.classList.toggle(cls)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add null guards for trigger and resolved target.

Line 7 will throw if no matching trigger element is found, and Lines 10/12/14 will throw when data-target-id does not resolve to an element.

💡 Proposed defensive fix
 export default class extends HTMLElement {
   connectedCallback () {
     const button = this.querySelector('a, button, label')
+    if (!button || !this.dataset.classes) return
 
-    const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
+    const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
+    if (!target) return
 
     button.addEventListener('click', () => {
       this.dataset.classes.split(' ').forEach((cls) => {
         if (this.dataset.action === 'remove') {
           target.classList.remove(cls)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/elements/toggle_classes.js` around lines 3 - 15, The click
handler assumes a valid trigger and target; add null guards so it doesn't throw:
verify that the computed trigger element (button) exists before calling
button.addEventListener and bail early if missing, ensure this.dataset.classes
is present and non-empty before splitting, and check that the resolved target
(the variable target from this.dataset.targetId or fallback to button) is
non-null before attempting target.classList.remove/add/toggle; update the logic
around the variables "button" and "target" in toggle_classes.js to perform these
checks and return early or skip class operations when they are absent.

Comment on lines +169 to +171
isNumberField (field) {
return field.type === 'number' || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isNumberField accepts invalid numeric strings.

On Line 170, /^[\d.-]+$/ matches values like --, 1-2, and 1.2.3, so non-numeric options can be treated as numeric and leak into formulas.

Proposed fix
 isNumberField (field) {
-  return field.type === 'number' || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
+  const isNumericValue = (value) => {
+    if (value === null || value === undefined) return false
+    const text = String(value).trim()
+    if (text === '') return false
+    return Number.isFinite(Number(text))
+  }
+
+  return field.type === 'number' || (
+    ['radio', 'select'].includes(field.type) &&
+    Array.isArray(field.options) &&
+    field.options.length > 0 &&
+    field.options.every((o) => isNumericValue(o.value))
+  )
 },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/template_builder/formula_modal.vue` around lines 169 - 171,
isNumberField currently treats strings like "--" or "1.2.3" as numeric because
the regex `/^[\d.-]+$/` is too permissive; update the validation inside
isNumberField (referencing field.type, field.options, and option value o.value)
to use a stricter numeric check — either replace the regex with one that allows
an optional sign and at most one decimal point (e.g.
/^[+-]?(?:\d+(\.\d+)?|\.\d+)$/) or validate via Number parsing combined with a
string-match to ensure no extra characters (e.g. Number.isFinite(Number(val))
plus confirming the original string matches the canonical numeric form). Ensure
the change is applied where options?.every((o) => ...) is used so only truly
numeric option values are treated as numbers.

Comment on lines +327 to 336
time_format = with_timestamp_seconds ? :detailed : :long

if with_signature_id_reason || field.dig('preferences', 'reasons').present?
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
else
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 -m pip install --quiet pyyaml 2>/dev/null || python -m pip install --quiet pyyaml

python3 - <<'PY'
import glob
import yaml
from collections import defaultdict

def deep_get(d, path):
    cur = d
    for key in path:
        if not isinstance(cur, dict) or key not in cur:
            return None
        cur = cur[key]
    return cur

seen_locales = set()
has_detailed = defaultdict(list)

for path in glob.glob('config/locales/**/*.y*ml', recursive=True):
    with open(path, 'r', encoding='utf-8') as f:
        data = yaml.safe_load(f) or {}
    for locale, body in data.items():
        seen_locales.add(locale)
        if deep_get(body, ['time', 'formats', 'detailed']) is not None:
            has_detailed[locale].append(path)

missing = sorted(locale for locale in seen_locales if locale not in has_detailed)

print("Locales found:", sorted(seen_locales))
print("Locales with time.formats.detailed:", sorted(has_detailed))
print("Locales missing time.formats.detailed:", missing)

if missing:
    print("\nMISSING DETAILED FORMAT - Would cause runtime error")
    exit(1)
else:
    print("\nAll locales have time.formats.detailed - Safe")
    exit(0)
PY

Repository: kencove/docuseal

Length of output: 517


Add time.formats.detailed locale key to 7 missing locales.

The :detailed timestamp format is missing from locales: ar, cs, he, ja, ko, pl, uk. When with_timestamp_seconds is true and a user is in one of these locales, I18n.l will raise a missing translation error during PDF generation, breaking attachment generation.

Add the detailed format to config/locales/*/time.yml for each missing locale, or use a fallback format key that exists across all locales.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/submissions/generate_result_attachments.rb` around lines 327 - 336, The
code sets time_format = with_timestamp_seconds ? :detailed : :long and then
calls I18n.l(..., format: time_format), which will raise missing translation
errors for locales that lack time.formats.detailed; update the affected locale
files to add a time.formats.detailed entry for ar, cs, he, ja, ko, pl, uk
(matching the desired detailed format), or change the code to use an existing
common key (e.g., :long) as a safe fallback when
I18n.exists?("time.formats.#{time_format}", locale) is false before calling
I18n.l; modify the logic around with_timestamp_seconds/time_format or perform an
existence check so I18n.l never requests a missing :detailed format.

Comment on lines +36 to +39
if (token = request.params[:two_factor_token].presence || request.headers['x-two-factor-token'].presence)
link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':')

return true if Submitter.signed_id_verifier.verified(token, purpose: :email_two_factor) == link_2fa_key
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle blank submitter email before building link_2fa_key.

Line 37 can raise when submitter.email is nil (e.g., phone-only submitters). Unauthorized access should return false, not 500.

🔧 Proposed fix
       if (token = request.params[:two_factor_token].presence || request.headers['x-two-factor-token'].presence)
-        link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':')
+        email = submitter.email.to_s.downcase.squish
+        template_slug = submitter.submission.template&.slug.to_s
+        return false if email.blank? || template_slug.blank?
+
+        link_2fa_key = [email, template_slug].join(':')

         return true if Submitter.signed_id_verifier.verified(token, purpose: :email_two_factor) == link_2fa_key
       end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/submitters/authorized_for_form.rb` around lines 36 - 39, The code builds
link_2fa_key using submitter.email without guarding for nil, causing a 500 for
phone-only submitters; update the two-factor check in authorized_for_form (the
block that reads request.params[:two_factor_token] and builds link_2fa_key) to
first return false if submitter.email.blank? (or otherwise handle nil) before
constructing link_2fa_key and verifying with
Submitter.signed_id_verifier.verified(token, purpose: :email_two_factor) so
missing emails produce a safe false result instead of raising.

RequiredFieldError = Class.new(StandardError)

VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
PHONE_REGEXP = /[+\d()\s-]+/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tighten phone matching to avoid misclassifying arbitrary text as a phone number.

Line 9 is unanchored, so Line 438 can match substrings (e.g., abc123) and treat invalid input as a phone invite target.

🔧 Proposed fix
-    PHONE_REGEXP = /[+\d()\s-]+/
+    PHONE_REGEXP = /\A(?=.*\d)[+\d()\s-]+\z/

Also applies to: 438-440

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/submitters/submit_values.rb` at line 9, The PHONE_REGEXP is too
permissive and unanchored (PHONE_REGEXP), causing substrings to match; change it
to an anchored pattern that enforces a minimum number of digits and full-string
matching (e.g. use \A and \z and a digit-count requirement such as a lookahead
like (?=(?:.*\d){7,}) or require at least 7 digit characters) and include only
allowed separators, then replace the current /[+\d()\s-]+/ with that anchored,
stricter regex so phone validation only matches entire valid phone strings
wherever PHONE_REGEXP is used.

Comment on lines +420 to +445
submission.template_submitters.each do |s|
field_uuid = s['invite_via_field_uuid']

next if field_uuid.blank?

field = submission.template_fields.find { |e| e['uuid'] == field_uuid }

next unless field
next unless field['submitter_uuid'] == submitter.uuid

next if submission.submitters.exists?(uuid: s['uuid'])

value = submitter.values[field_uuid]

next if value.blank?

if value.include?('@')
email = Submissions.normalize_email(value)
elsif value.match?(PHONE_REGEXP)
phone = value.gsub(/[^+\d]/, '')
end

next if email.blank? && phone.blank?

submission.submitters.create!(uuid: s['uuid'], email:, phone:, account_id: submitter.account_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reset email/phone for each invite candidate to prevent cross-recipient leakage.

Line 444 can reuse email/phone from a previous loop iteration because those locals are not reinitialized inside the loop. That can create invites for the wrong contact.

🔒 Proposed fix
       submission.template_submitters.each do |s|
+        email = nil
+        phone = nil
         field_uuid = s['invite_via_field_uuid']
@@
-        if value.include?('@')
+        if value.include?('@')
           email = Submissions.normalize_email(value)
         elsif value.match?(PHONE_REGEXP)
           phone = value.gsub(/[^+\d]/, '')
         end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/submitters/submit_values.rb` around lines 420 - 445, The locals email and
phone can leak values between iterations of the submission.template_submitters
loop; reinitialize email and phone to nil at the top of the loop (just after
obtaining field_uuid or before checking value) so each invite candidate starts
with a fresh contact; ensure the existing logic using PHONE_REGEXP,
Submissions.normalize_email, and submission.submitters.create! uses these
reinitialized variables to avoid cross-recipient leakage.

Comment on lines +456 to +458
def validate_value!(_value, field, _params, submitter, _request)
if field['readonly'] == true
Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard unknown field UUIDs before readonly access.

Line 457 assumes field exists. If a submitted UUID is not in template_fields, this will raise NoMethodError and return 500 instead of a controlled validation error.

🛡️ Proposed fix
     def validate_value!(_value, field, _params, submitter, _request)
+      raise ValidationError, 'Unknown field' if field.blank?
+
       if field['readonly'] == true
         Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def validate_value!(_value, field, _params, submitter, _request)
if field['readonly'] == true
Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar)
def validate_value!(_value, field, _params, submitter, _request)
raise ValidationError, 'Unknown field' if field.blank?
if field['readonly'] == true
Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/submitters/submit_values.rb` around lines 456 - 458, In validate_value!
ensure you guard against a nil/unknown field before accessing field['readonly']
and field['uuid']; if field is nil (meaning the submitted UUID wasn't found in
template_fields) raise or return the controlled validation error instead of
touching field['readonly'], and keep the Rollbar.warning call only after
confirming field is present (reference validate_value!, field['readonly'], and
field['uuid'] to locate the checks).

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

if (this.editor) {
this.editor.destroy()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null guard crashes disconnectedCallback on early teardown

Medium Severity

disconnectedCallback calls this.linkTooltip.hide() without a null check, but this.linkTooltip is only assigned at the very end of the async connectedCallback (after await loadTiptap()). If the element is removed from the DOM before the async loading completes, this.linkTooltip is undefined and .hide() throws a TypeError. The this.editor check on the next line correctly uses a guard (if (this.editor)), but this.linkTooltip lacks the same protection.

Additional Locations (1)

Fix in Cursor Fix in Web

<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %>
<% if url_params[:action] == 'new' %>
<open-modal src="<%= params[:modal] %>"></open-modal>
<open-modal src="<%= url_for(url_params) %>"></open-modal>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled recognize_path error crashes page on invalid modal param

Medium Severity

Rails.application.routes.recognize_path(params[:modal]) raises ActionController::RoutingError if params[:modal] contains an unrecognized path. Since this runs in the application layout during view rendering with no rescue, the entire page returns a 404 instead of rendering normally. The previous code used params[:modal] directly as the src attribute, which would fail gracefully at the AJAX level without breaking the page.

Fix in Cursor Fix in Web

…llback tests

- Add test for Faraday::ConnectionFailed retry in SMS job spec (the job
  rescues this error but only Faraday::TimeoutError was previously tested)
- Add STORE describe block in rate_limit_spec to verify MemoryStore
  fallback when REDIS_URL is absent, rescue behavior on Redis errors,
  and that the store supports increment for rate limiting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dnplkndll
Copy link
Copy Markdown
Author

Addressed the SMS-specific review comments:

  • Added Faraday::ConnectionFailed test coverage in spec/jobs/send_submitter_invitation_sms_job_spec.rb — the job rescues this error alongside Faraday::TimeoutError, but only the timeout path was tested. Now both network failure modes have explicit specs.
  • Added Redis fallback tests in spec/lib/rate_limit_spec.rb — new STORE describe block verifying MemoryStore is used when REDIS_URL is absent, the rescue fallback behavior on Redis connection errors, and that the store responds to increment (the operation used for rate limiting).

The remaining comments about upstream code (markdown_editor.js, toggle_attribute.js, toggle_classes.js, formula_modal.vue, send_test_webhook_request_job.rb, submitter_mailer.rb, view templates, config/dotenv.rb, download_utils.rb, generate_result_attachments.rb, authorized_for_form.rb, submit_values.rb, signature_step.vue, create_attachments.rb, submitters.rb, etc.) are inherited from the kencove base branch and are out of scope for this SMS-focused PR — will address those separately.

Use do...end for multi-line expect blocks and modifier if for
single-line conditionals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dnplkndll dnplkndll merged commit 036a514 into kencove Feb 28, 2026
3 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants