-
# typed: false
-
# frozen_string_literal: true
-
-
4
class AdminController < ApplicationController
-
4
before_action :require_admin
-
-
4
def index
-
10
@show_backups = ENV["USE_S3_STORAGE"] == "true"
-
end
-
-
4
def releases
-
6
@releases = Rails.cache.fetch("github_releases", expires_in: 1.hour) do
-
5
fetch_github_releases
-
end
-
rescue => e
-
1
Rails.logger.error "Failed to fetch GitHub releases: #{e.message}"
-
1
@releases = []
-
1
flash.now[:error] = t("admin.releases.fetch_error")
-
end
-
-
4
def files
-
4
@blobs = ActiveStorage::Blob
-
.where.not(id: ActiveStorage::VariantRecord.select(:blob_id))
-
.includes(attachments: :record)
-
.order(created_at: :desc)
-
end
-
-
4
private
-
-
4
def fetch_github_releases
-
2
response = make_github_api_request
-
2
parse_github_response(response)
-
end
-
-
4
def make_github_api_request
-
require "net/http"
-
-
uri = URI("https://api.github.com/repos/chobbledotcom/play-test/releases")
-
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
http.read_timeout = 10
-
-
request = Net::HTTP::Get.new(uri)
-
request["Accept"] = "application/vnd.github.v3+json"
-
request["User-Agent"] = "PlayTest-Admin"
-
-
http.request(request)
-
end
-
-
4
def parse_github_response(response)
-
2
require "json"
-
-
2
then: 2
if response.code == "200"
-
4
JSON.parse(response.body).map { |release| format_release(release) }
-
else: 0
else
-
log_msg = "GitHub API returned #{response.code}: #{response.body}"
-
Rails.logger.error log_msg
-
[]
-
end
-
end
-
-
4
def format_release(release)
-
{
-
2
name: release["name"],
-
tag_name: release["tag_name"],
-
published_at: Time.zone.parse(release["published_at"]),
-
body: process_release_body(release["body"]),
-
html_url: release["html_url"],
-
author: release["author"]["login"],
-
is_bot: release["author"]["login"].include?("[bot]")
-
}
-
end
-
-
4
def process_release_body(body)
-
2
then: 0
else: 2
return nil if body.blank?
-
-
# Find the position of "## Docker Images" and truncate
-
2
docker_index = body.index("## Docker Images")
-
2
then: 1
else: 1
processed_body = docker_index ? body[0...docker_index].strip : body
-
-
# Remove [Read the full changelog here] links
-
2
changelog_pattern = /\[Read the full changelog here\]\([^)]+\)/
-
2
processed_body = processed_body.gsub(changelog_pattern, "")
-
2
processed_body = processed_body.strip
-
-
2
convert_markdown_to_html(processed_body)
-
end
-
-
4
def convert_markdown_to_html(text)
-
# Remove headers (they duplicate version info)
-
2
html = text.gsub(/^### .+$/, "")
-
2
html = html.gsub(/^## .+$/, "")
-
2
html = html.gsub(/^# .+$/, "")
-
-
# Clean up extra newlines from removed headers
-
2
html = html.gsub(/\n{3,}/, "\n\n").strip
-
-
# Convert bold and bullet points
-
2
html = html.gsub(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
-
2
html = convert_bullet_points(html)
-
-
# Convert line breaks to paragraphs
-
2
wrap_paragraphs(html)
-
end
-
-
4
def convert_bullet_points(text)
-
2
html = text.gsub(/^- (.+)$/, '<li>\1</li>')
-
3
html.gsub(/(<li>.*<\/li>)/m) { |match| "<ul>#{match}</ul>" }
-
end
-
-
4
def wrap_paragraphs(text)
-
2
text.split("\n\n").map do |paragraph|
-
3
then: 0
else: 3
next if paragraph.strip.empty?
-
3
then: 1
if paragraph.include?("<h") || paragraph.include?("<ul>")
-
1
paragraph
-
else: 2
else
-
2
"<p>#{paragraph}</p>"
-
end
-
end.compact.join("\n")
-
end
-
end
-
4
class AnchorageAssessmentsController < ApplicationController
-
4
include AssessmentController
-
4
include SafetyStandardsTurboStreams
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
class ApplicationController < ActionController::Base
-
4
extend T::Sig
-
4
include SessionsHelper
-
4
include ImageProcessable
-
-
4
before_action :require_login, unless: :skip_authentication?
-
4
before_action :update_last_active_at, unless: :skip_authentication?
-
-
4
before_action :start_debug_timer, if: :admin_debug_enabled?
-
4
after_action :cleanup_debug_subscription, if: :admin_debug_enabled?
-
-
2794
around_action :n_plus_one_detection, unless: -> { Rails.env.production? || skip_authentication? }
-
-
4
rescue_from StandardError do |exception|
-
18
then: 4
else: 14
if Rails.env.production? && should_notify_error?(exception)
-
4
then: 3
else: 1
user_email = current_user&.email || app_i18n(:errors, :not_logged_in)
-
4
user_label = app_i18n(:errors, :user_label)
-
4
user_info = "#{user_label}: #{user_email}"
-
-
4
controller_label = app_i18n(:errors, :controller_label)
-
4
path_label = app_i18n(:errors, :path_label)
-
4
method_label = app_i18n(:errors, :method_label)
-
4
ip_label = app_i18n(:errors, :ip_label)
-
4
backtrace_label = app_i18n(:errors, :backtrace_label)
-
4
error_subject = app_i18n(:errors, :production_error_subject)
-
-
message = <<~MESSAGE
-
4
#{error_subject}
-
-
#{exception.class}: #{exception.message}
-
-
#{user_info}
-
#{controller_label}: #{controller_name}##{action_name}
-
#{path_label}: #{request.fullpath}
-
#{method_label}: #{request.request_method}
-
#{ip_label}: #{request.remote_ip}
-
-
#{backtrace_label}:
-
#{exception.backtrace.first(5).join("\n")}
-
MESSAGE
-
-
4
NtfyService.notify(message)
-
end
-
-
18
raise exception
-
end
-
-
4
private
-
-
8
sig { returns(T::Boolean) }
-
4
def skip_authentication?
-
8110
false
-
end
-
-
5
sig { params(table: Symbol, key: Symbol, args: T.untyped).returns(String) }
-
4
def app_i18n(table, key, **args)
-
31
I18n.t("application.#{table}.#{key}", **args)
-
end
-
-
8
sig { params(form: Symbol, key: T.any(Symbol, String), args: T.untyped).returns(String) }
-
4
def form_i18n(form, key, **args)
-
42
I18n.t("forms.#{form}.#{key}", **args)
-
end
-
-
8
sig { void }
-
4
def require_login
-
1247
then: 1209
else: 38
return if logged_in?
-
-
38
flash[:alert] = form_i18n(:session_new, :"status.login_required")
-
38
redirect_to login_path
-
end
-
-
8
sig { void }
-
4
def require_logged_out
-
1000
else: 3
then: 997
return unless logged_in?
-
-
3
flash[:alert] = form_i18n(:session_new, :"status.already_logged_in")
-
3
redirect_to inspections_path
-
end
-
-
8
sig { void }
-
4
def update_last_active_at
-
2773
else: 1484
then: 1289
return unless current_user.is_a?(User)
-
-
1484
current_user.update(last_active_at: Time.current)
-
-
# Update UserSession last_active_at
-
1484
then: 1445
else: 39
if session[:session_token]
-
1445
then: 1445
else: 0
current_session&.touch_last_active
-
end
-
end
-
-
8
sig { void }
-
4
def require_admin
-
208
then: 207
else: 1
then: 170
else: 38
return if current_user&.admin?
-
-
38
flash[:alert] = I18n.t("forms.session_new.status.admin_required")
-
38
redirect_to root_path
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def admin_debug_enabled?
-
7047
Rails.env.development?
-
end
-
-
5
sig { returns(T::Boolean) }
-
4
def seed_data_action?
-
4
seed_actions = %w[add_seeds delete_seeds]
-
4
controller_name == "users" && seed_actions.include?(action_name)
-
end
-
-
5
sig { returns(T::Boolean) }
-
4
def impersonating?
-
2
session[:original_admin_id].present?
-
end
-
-
5
sig { void }
-
4
def start_debug_timer
-
1
@debug_start_time = Time.current
-
1
@debug_sql_queries = []
-
-
1
then: 0
else: 1
ActiveSupport::Notifications.unsubscribe(@debug_subscription) if @debug_subscription
-
-
1
@debug_subscription = ActiveSupport::Notifications
-
.subscribe("sql.active_record") do |_name, start, finish, _id, payload|
-
11
else: 0
then: 11
unless payload[:name] == "SCHEMA" || payload[:sql] =~ /^PRAGMA/
-
11
@debug_sql_queries << {
-
sql: payload[:sql],
-
11
duration: ((finish - start) * 1000).round(2),
-
name: payload[:name]
-
}
-
end
-
end
-
end
-
-
# Make debug data available to views
-
4
helper_method :admin_debug_enabled?,
-
:impersonating?,
-
:debug_render_time,
-
:debug_sql_queries
-
-
5
sig { returns(T.nilable(Float)) }
-
4
def debug_render_time
-
2
else: 1
then: 1
return unless @debug_start_time
-
-
1
((Time.current - @debug_start_time) * 1000).round(2)
-
end
-
-
7
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
-
4
def debug_sql_queries
-
47
@debug_sql_queries || []
-
end
-
-
8
sig { void }
-
4
def n_plus_one_detection
-
2787
Prosopite.scan
-
2787
yield
-
ensure
-
2787
Prosopite.finish
-
end
-
-
5
sig { returns(T::Boolean) }
-
4
def processing_image_upload?
-
5
case controller_name
-
when: 2
when "users"
-
2
action_name == "update_settings" && params.dig(:user, :logo).present?
-
when: 2
when "units"
-
2
%w[create update].include?(action_name) &&
-
params.dig(:unit, :photo).present?
-
else: 1
else
-
1
false
-
end
-
end
-
-
5
sig { void }
-
4
def cleanup_debug_subscription
-
1
else: 1
then: 0
return unless @debug_subscription
-
-
1
ActiveSupport::Notifications.unsubscribe(@debug_subscription)
-
1
@debug_subscription = nil
-
end
-
-
5
sig { params(exception: StandardError).returns(T::Boolean) }
-
4
def should_notify_error?(exception)
-
9
then: 4
else: 5
if exception.is_a?(ActionController::InvalidAuthenticityToken)
-
csrf_ignored_actions = [
-
4
%w[sessions create],
-
%w[users create]
-
]
-
-
4
action = [controller_name, action_name]
-
4
then: 3
else: 1
return false if csrf_ignored_actions.include?(action)
-
end
-
-
6
then: 0
else: 6
if exception.is_a?(ActionController::InvalidCrossOriginRequest)
-
else: 0
then: 0
return false unless logged_in?
-
end
-
-
6
true
-
end
-
-
7
sig { params(result: T.untyped, filename: String).void }
-
4
def handle_pdf_response(result, filename)
-
45
else: 0
case result.type
-
when: 0
when :redirect
-
Rails.logger.info "PDF response: Redirecting to S3 URL for #{filename}"
-
redirect_to result.data, allow_other_host: true
-
when: 0
when :stream
-
Rails.logger.info "PDF response: Streaming #{filename} from S3 through Rails"
-
expires_in 0, public: false
-
send_data result.data.download,
-
filename: filename,
-
type: "application/pdf",
-
disposition: "inline"
-
when: 45
when :pdf_data
-
45
Rails.logger.info "PDF response: Sending generated PDF data for #{filename}"
-
45
send_data result.data,
-
filename: filename,
-
type: "application/pdf",
-
disposition: "inline"
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
4
class BackupsController < ApplicationController
-
4
before_action :require_admin
-
4
before_action :ensure_s3_enabled
-
-
4
def index
-
2
@backups = fetch_backups
-
end
-
-
4
def download
-
5
date = params[:date]
-
-
5
else: 2
then: 3
return redirect_with_error("invalid_date") unless valid_date?(date)
-
-
2
backup_key = build_backup_key(date)
-
2
else: 1
then: 1
unless backup_exists?(backup_key)
-
1
return redirect_with_error("backup_not_found")
-
end
-
-
1
presigned_url = generate_download_url(backup_key)
-
1
redirect_to presigned_url, allow_other_host: true
-
end
-
-
4
private
-
-
4
def ensure_s3_enabled
-
8
then: 7
else: 1
return if ENV["USE_S3_STORAGE"] == "true"
-
-
1
flash[:error] = t("backups.errors.s3_not_enabled")
-
1
redirect_to admin_path
-
end
-
-
4
def get_s3_service
-
5
service = ActiveStorage::Blob.service
-
-
# Only check S3Service class if it's loaded (production/S3 environments)
-
5
then: 0
else: 5
if defined?(ActiveStorage::Service::S3Service)
-
else: 0
then: 0
unless service.is_a?(ActiveStorage::Service::S3Service)
-
raise t("backups.errors.s3_not_configured")
-
end
-
end
-
-
5
service
-
end
-
-
4
def fetch_backups
-
4
service = get_s3_service
-
4
bucket = service.send(:bucket)
-
-
3
backups = build_backup_list(bucket)
-
6
backups.sort_by { |b| b[:last_modified] }.reverse
-
end
-
-
4
def backup_exists?(key)
-
3
fetch_backups.any? { |backup| backup[:key] == key }
-
end
-
-
4
def redirect_with_error(error_key)
-
4
flash[:error] = t("backups.errors.#{error_key}")
-
4
redirect_to backups_path
-
end
-
-
4
def generate_download_url(backup_key)
-
1
service = get_s3_service
-
1
bucket = service.send(:bucket)
-
1
object = bucket.object(backup_key)
-
-
1
filename = File.basename(backup_key)
-
1
disposition = build_content_disposition(filename)
-
-
1
object.presigned_url(
-
:get,
-
expires_in: 300,
-
response_content_disposition: disposition
-
)
-
end
-
-
4
def build_backup_list(bucket)
-
3
backups = []
-
3
bucket.objects(prefix: "db_backups/").each do |object|
-
3
else: 3
then: 0
next unless valid_backup_filename?(object.key)
-
-
3
backups << build_backup_info(object)
-
end
-
3
backups
-
end
-
-
4
def valid_backup_filename?(key)
-
3
key.match?(/database-\d{4}-\d{2}-\d{2}\.tar\.gz$/)
-
end
-
-
4
def build_backup_info(object)
-
{
-
3
key: object.key,
-
filename: File.basename(object.key),
-
size: object.size,
-
last_modified: object.last_modified,
-
size_mb: calculate_size_in_mb(object.size)
-
}
-
end
-
-
4
def calculate_size_in_mb(size_bytes)
-
3
(size_bytes / 1024.0 / 1024.0).round(2)
-
end
-
-
4
def build_content_disposition(filename)
-
1
"attachment; filename=\"#{filename}\""
-
end
-
-
4
def valid_date?(date)
-
5
else: 3
then: 2
return false unless date.is_a?(String) && date.present?
-
-
3
date.match?(/\A\d{4}-\d{2}-\d{2}\z/) && Date.parse(date)
-
rescue Date::Error
-
false
-
end
-
-
4
def build_backup_key(date)
-
2
"db_backups/database-#{date}.tar.gz"
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module AssessmentController
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
4
include UserActivityCheck
-
4
include InspectionTurboStreams
-
-
4
included do
-
28
before_action :set_inspection
-
28
before_action :check_inspection_owner
-
28
before_action :require_user_active
-
28
before_action :set_assessment
-
28
before_action :set_previous_inspection
-
end
-
-
8
sig { void }
-
4
def update
-
# Apply any model-specific preprocessing (like setting defaults)
-
56
then: 15
else: 41
preprocess_values if respond_to?(:preprocess_values, true)
-
-
56
then: 55
if @assessment.update(assessment_params)
-
55
handle_successful_update
-
else: 1
else
-
1
handle_failed_update
-
end
-
end
-
-
4
private
-
-
8
sig { void }
-
4
def handle_successful_update
-
55
additional_info = build_additional_info
-
-
55
respond_to do |format|
-
55
format.html do
-
38
flash[:notice] = build_flash_message(additional_info)
-
38
redirect_to @inspection
-
end
-
55
format.json do
-
success_response = {
-
status: I18n.t("shared.api.success"),
-
inspection: @inspection
-
}
-
render json: success_response
-
end
-
55
format.turbo_stream do
-
17
render turbo_stream: success_turbo_streams(additional_info:)
-
end
-
end
-
end
-
-
# Override in specific controllers to provide additional info
-
7
sig { returns(T.nilable(String)) }
-
4
def build_additional_info
-
41
nil
-
end
-
-
8
sig { params(additional_info: T.nilable(String)).returns(String) }
-
4
def build_flash_message(additional_info)
-
38
base_message = I18n.t("inspections.messages.updated")
-
38
else: 6
then: 32
return base_message unless additional_info
-
-
6
"#{base_message} #{additional_info}"
-
end
-
-
5
sig { void }
-
4
def handle_failed_update
-
1
respond_to do |format|
-
2
format.html { render_edit_with_errors }
-
1
format.json do
-
errors = {errors: @assessment.errors}
-
render json: errors, status: :unprocessable_content
-
end
-
1
format.turbo_stream { render turbo_stream: error_turbo_streams }
-
end
-
end
-
-
5
sig { void }
-
4
def render_edit_with_errors
-
1
params[:tab] = assessment_type
-
1
@inspection.association(assessment_association).target = @assessment
-
1
render "inspections/edit", status: :unprocessable_content
-
end
-
-
8
sig { void }
-
4
def set_inspection
-
56
@inspection = Inspection
-
.includes(
-
:user,
-
:inspector_company,
-
:unit,
-
*Inspection::ALL_ASSESSMENT_TYPES.keys
-
)
-
.find(params[:inspection_id])
-
end
-
-
8
sig { void }
-
4
def check_inspection_owner
-
56
else: 56
then: 0
head :not_found unless @inspection.user == current_user
-
end
-
-
8
sig { void }
-
4
def set_assessment
-
56
@assessment = @inspection.send(assessment_association)
-
end
-
-
# Default implementation that permits all attributes except sensitive ones
-
# Can be overridden in including controllers if needed
-
8
sig { returns(ActionController::Parameters) }
-
4
def assessment_params
-
56
params.require(param_key).permit(permitted_attributes)
-
end
-
-
8
sig { returns(Symbol) }
-
4
def param_key
-
# Use the model's actual param_key to avoid namespace mismatches
-
56
assessment_class.model_name.param_key.to_sym
-
end
-
-
8
sig { returns(T::Array[String]) }
-
4
def permitted_attributes
-
# Get all attributes except sensitive ones
-
56
excluded_attrs = %w[id inspection_id created_at updated_at]
-
56
assessment_class.attribute_names - excluded_attrs
-
end
-
-
# Automatically derive from controller name
-
8
sig { returns(String) }
-
4
def assessment_association
-
# e.g. "MaterialsAssessmentsController" -> "materials_assessment"
-
57
controller_name.singularize
-
end
-
-
# Automatically derive from controller name
-
7
sig { returns(String) }
-
4
def assessment_type
-
# e.g. "MaterialsAssessmentsController" -> "materials"
-
19
controller_name.singularize.sub(/_assessment$/, "")
-
end
-
-
# Automatically derive from controller name
-
8
sig { returns(T.class_of(ActiveRecord::Base)) }
-
4
def assessment_class
-
# e.g. "MaterialsAssessmentsController"
-
# -> Assessments::MaterialsAssessment
-
129
"Assessments::#{controller_name.singularize.camelize}".constantize
-
end
-
-
8
sig { void }
-
4
def set_previous_inspection
-
56
else: 56
then: 0
return unless @inspection.unit
-
-
56
@previous_inspection = @inspection.unit.last_inspection
-
end
-
-
4
sig { void }
-
4
def handle_inactive_user_redirect
-
redirect_to edit_inspection_path(@inspection)
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
require "vips"
-
-
4
module ImageProcessable
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
included do
-
4
rescue_from ApplicationErrors::NotAnImageError do |exception|
-
handle_image_error(exception)
-
end
-
-
4
rescue_from ApplicationErrors::ImageProcessingError do |exception|
-
handle_image_error(exception)
-
end
-
end
-
-
4
private
-
-
4
sig {
-
4
params(
-
params_hash: T.any(ActionController::Parameters,
-
T::Hash[T.untyped, T.untyped]),
-
image_fields: T.untyped
-
).returns(T.any(ActionController::Parameters,
-
T::Hash[T.untyped, T.untyped]))
-
}
-
4
def process_image_params(params_hash, *image_fields)
-
138
image_fields.each do |field|
-
348
then: 331
else: 17
next if params_hash[field].blank?
-
-
17
uploaded_file = params_hash[field]
-
17
else: 17
then: 0
next unless uploaded_file.respond_to?(:read)
-
-
begin
-
17
processed_io = process_image(uploaded_file)
-
14
then: 14
else: 0
params_hash[field] = processed_io if processed_io
-
rescue ApplicationErrors::NotAnImageError,
-
ApplicationErrors::ImageProcessingError => e
-
3
@image_processing_error = e
-
3
params_hash[field] = nil
-
end
-
end
-
-
138
params_hash
-
end
-
-
7
sig { params(uploaded_file: T.untyped).returns(T.untyped) }
-
4
def process_image(uploaded_file)
-
17
validate_image!(uploaded_file)
-
-
14
processed_io = PhotoProcessingService.process_upload(uploaded_file)
-
14
else: 14
then: 0
raise ApplicationErrors::ImageProcessingError unless processed_io
-
-
14
processed_io
-
rescue Vips::Error => e
-
Rails.logger.error "Image processing failed: #{e.message}"
-
error_message = I18n.t("errors.messages.image_processing_error",
-
error: e.message)
-
raise ApplicationErrors::ImageProcessingError, error_message
-
end
-
-
7
sig { params(uploaded_file: T.untyped).void }
-
4
def validate_image!(uploaded_file)
-
17
then: 14
else: 3
return if PhotoProcessingService.valid_image?(uploaded_file)
-
3
raise ApplicationErrors::NotAnImageError
-
end
-
-
4
sig { params(exception: StandardError).void }
-
4
def handle_image_error(exception)
-
respond_to do |format|
-
format.html do
-
flash[:alert] = exception.message
-
redirect_back(fallback_location: root_path)
-
end
-
format.turbo_stream do
-
flash.now[:alert] = exception.message
-
render turbo_stream: turbo_stream.replace("flash",
-
partial: "shared/flash")
-
end
-
end
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module InspectionTurboStreams
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
private
-
-
4
sig do
-
4
params(additional_info: T.nilable(String)).returns(T::Array[T.untyped])
-
end
-
4
def success_turbo_streams(additional_info: nil)
-
[
-
27
mark_complete_section_stream,
-
save_message_stream(success: true),
-
assessment_save_message_stream(success: true, additional_info:),
-
*photo_update_streams
-
].compact
-
end
-
-
5
sig { returns(T::Array[T.untyped]) }
-
4
def error_turbo_streams
-
[
-
1
mark_complete_section_stream,
-
save_message_stream(success: false),
-
assessment_save_message_stream(success: false)
-
]
-
end
-
-
8
sig { returns(T.untyped) }
-
4
def mark_complete_section_stream
-
28
turbo_stream.replace(
-
"mark_complete_section_#{@inspection.id}",
-
partial: "inspections/mark_complete_section",
-
locals: {inspection: @inspection}
-
)
-
end
-
-
8
sig { params(success: T::Boolean).returns(T.untyped) }
-
4
def save_message_stream(success:)
-
28
turbo_stream.replace(
-
"inspection_save_message",
-
partial: "shared/save_message",
-
locals: save_message_locals(
-
success: success,
-
dom_id: "inspection_save_message"
-
)
-
)
-
end
-
-
4
sig do
-
4
params(success: T::Boolean, additional_info: T.nilable(String))
-
.returns(T.untyped)
-
end
-
4
def assessment_save_message_stream(success:, additional_info: nil)
-
28
locals = save_message_locals(success:, dom_id: "form_save_message")
-
28
then: 2
else: 26
locals[:additional_info] = additional_info if additional_info
-
-
28
turbo_stream.replace(
-
"form_save_message",
-
partial: "shared/save_message",
-
locals:
-
)
-
end
-
-
4
sig do
-
4
params(success: T::Boolean, dom_id: String)
-
.returns(T::Hash[Symbol, T.untyped])
-
end
-
4
def save_message_locals(success:, dom_id:)
-
56
then: 54
if success
-
54
success_message_locals(dom_id)
-
else: 2
else
-
{
-
2
dom_id: dom_id,
-
errors: @inspection.errors.full_messages,
-
message: t("shared.messages.save_failed")
-
}
-
end
-
end
-
-
8
sig { params(dom_id: String).returns(T::Hash[Symbol, T.untyped]) }
-
4
def success_message_locals(dom_id)
-
54
current_tab_name = params[:tab].presence || "inspection"
-
54
nav_info = helpers.next_tab_navigation_info(@inspection, current_tab_name)
-
-
{
-
54
dom_id: dom_id,
-
success: true,
-
message: t("inspections.messages.updated"),
-
inspection: @inspection
-
}.tap do |locals|
-
54
then: 52
else: 2
add_navigation_info(locals, nav_info) if nav_info
-
end
-
end
-
-
4
sig do
-
3
params(
-
locals: T::Hash[Symbol, T.untyped],
-
nav_info: T::Hash[Symbol, T.untyped]
-
).void
-
end
-
4
def add_navigation_info(locals, nav_info)
-
52
locals[:next_tab] = nav_info[:tab]
-
52
locals[:skip_incomplete] = nav_info[:skip_incomplete]
-
52
then: 50
else: 2
if nav_info[:skip_incomplete]
-
50
locals[:incomplete_count] = nav_info[:incomplete_count]
-
end
-
end
-
-
8
sig { returns(T::Array[T.untyped]) }
-
4
def photo_update_streams
-
27
else: 10
then: 17
return [] unless params[:inspection]
-
-
10
%i[photo_1 photo_2 photo_3].filter_map do |photo_field|
-
30
then: 30
else: 0
next if params[:inspection][photo_field].blank?
-
-
turbo_stream.replace(
-
"inspection_#{photo_field}_field",
-
partial: "chobble_forms/file_field_turbo_response",
-
locals: {
-
model: @inspection,
-
field: photo_field,
-
turbo_frame_id: "inspection_#{photo_field}_field",
-
i18n_base: "forms.results",
-
accept: "image/*"
-
}
-
)
-
end
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module PublicViewable
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
included do
-
8
before_action :check_resource_access, only: %i[show]
-
end
-
-
4
sig { void }
-
4
def show
-
# Implemented by including controllers, but we call render_show_html
-
# for HTML format
-
then: 0
else: 0
if request.format.html?
-
render_show_html
-
end
-
end
-
-
4
private
-
-
# Access Rules:
-
# 1. PDF/JSON/PNG formats: Always allowed for everyone (logged in or not)
-
# 2. HTML format:
-
# - Not logged in: Allowed, shows minimal PDF viewer
-
# - Logged in as owner: Allowed, shows full application view
-
# - Logged in as non-owner: Allowed, shows minimal PDF viewer
-
# 3. All other actions/formats: Require ownership
-
8
sig { void }
-
4
def check_resource_access
-
# Rule 1: Always allow PDF/JSON/PNG access for everyone
-
288
then: 101
else: 187
return if request.format.pdf? || request.format.json? || request.format.png?
-
-
# Rule 2: Always allow HTML access (show action decides the view)
-
187
then: 181
else: 6
return if request.format.html? && action_name == "show"
-
-
# Rule 3: Always allow HEAD requests for federation
-
6
then: 6
else: 0
return if request.head?
-
-
# Rule 4: All other cases require ownership
-
check_resource_owner
-
end
-
-
# To be implemented by including controllers
-
4
sig { void }
-
4
def check_resource_owner
-
raise NotImplementedError
-
end
-
-
# Determine if current user owns the resource
-
4
sig { returns(T::Boolean) }
-
4
def owns_resource?
-
raise NotImplementedError
-
end
-
-
# Render appropriate view for show action
-
8
sig { void }
-
4
def render_show_html
-
167
else: 144
if !logged_in? || !owns_resource?
-
then: 23
# Show minimal PDF viewer for public access or non-owners
-
23
@pdf_title = pdf_filename
-
23
@pdf_url = resource_pdf_url
-
23
render layout: "pdf_viewer"
-
end
-
# Otherwise render normal view for owners (no explicit render needed)
-
end
-
-
# To be implemented by including controllers
-
4
sig { returns(String) }
-
4
def pdf_filename
-
raise NotImplementedError
-
end
-
-
4
sig { returns(String) }
-
4
def resource_pdf_url
-
raise NotImplementedError
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module SafetyStandardsTurboStreams
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
private
-
-
4
sig do
-
3
params(additional_info: T.nilable(String)).returns(T::Array[T.untyped])
-
end
-
4
def success_turbo_streams(additional_info: nil)
-
9
super + safety_standards_turbo_streams
-
end
-
-
7
sig { returns(T::Array[T.untyped]) }
-
4
def safety_standards_turbo_streams
-
9
[turbo_stream.replace(
-
safety_results_frame_id,
-
partial: safety_results_partial,
-
locals: {assessment: @assessment}
-
)]
-
end
-
-
7
sig { returns(String) }
-
4
def safety_results_frame_id
-
9
"#{assessment_type}_safety_results"
-
end
-
-
7
sig { returns(String) }
-
4
def safety_results_partial
-
9
"assessments/#{assessment_type}_safety_results"
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
module SessionManagement
-
4
extend ActiveSupport::Concern
-
-
4
private
-
-
4
def establish_user_session(user)
-
609
user_session = user.user_sessions.create!(
-
ip_address: request.remote_ip,
-
user_agent: request.user_agent,
-
last_active_at: Time.current
-
)
-
-
609
session[:session_token] = user_session.session_token
-
609
create_user_session(user)
-
-
609
user_session
-
end
-
-
4
def terminate_current_session
-
29
else: 28
then: 1
return unless session[:session_token]
-
-
28
then: 28
else: 0
UserSession.find_by(session_token: session[:session_token])&.destroy
-
28
session.delete(:session_token)
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module TurboStreamResponders
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
private
-
-
7
sig { params(success: T::Boolean, message: String, model: T.nilable(ActiveRecord::Base), additional_streams: T::Array[Turbo::Streams::TagBuilder]).void }
-
4
def render_save_message_stream(success:, message:, model: nil, additional_streams: [])
-
streams = [
-
6
turbo_stream.replace(
-
"form_save_message",
-
partial: "shared/save_message",
-
locals: {
-
message: message,
-
6
then: 5
else: 1
type: success ? "success" : "error",
-
6
then: 5
else: 1
then: 1
else: 0
then: 1
else: 0
errors: success ? nil : model&.errors&.full_messages
-
}
-
)
-
]
-
-
6
then: 1
else: 5
streams.concat(additional_streams) if additional_streams.any?
-
-
6
render turbo_stream: streams
-
end
-
-
8
sig { params(model: ActiveRecord::Base, message_key: T.nilable(String), redirect_path: T.any(String, ActiveRecord::Base, NilClass), additional_streams: T::Array[Turbo::Streams::TagBuilder]).void }
-
4
def handle_update_success(model, message_key = nil, redirect_path = nil, additional_streams: [])
-
32
message_key ||= "#{model.class.table_name}.messages.updated"
-
32
redirect_path ||= model
-
-
32
respond_to do |format|
-
32
format.html do
-
27
flash[:notice] = I18n.t(message_key)
-
27
redirect_to redirect_path
-
end
-
32
format.turbo_stream do
-
5
render_save_message_stream(
-
success: true,
-
message: I18n.t(message_key),
-
additional_streams: additional_streams
-
)
-
end
-
end
-
end
-
-
8
sig { params(model: ActiveRecord::Base, view: Symbol, block: T.nilable(T.proc.params(format: T.untyped).void)).void }
-
4
def handle_update_failure(model, view = :edit, &block)
-
8
respond_to do |format|
-
15
format.html { render view, status: :unprocessable_content }
-
8
format.json do
-
render json: {
-
status: I18n.t("shared.api.error"),
-
errors: model.errors.full_messages
-
}
-
end
-
8
format.turbo_stream do
-
1
render_save_message_stream(
-
success: false,
-
message: I18n.t("shared.messages.save_failed"),
-
model: model
-
)
-
end
-
8
then: 0
else: 8
yield(format) if block_given?
-
end
-
end
-
-
8
sig { params(model: ActiveRecord::Base, message_key: T.nilable(String)).void }
-
4
def handle_create_success(model, message_key = nil)
-
22
message_key ||= "#{model.class.table_name}.messages.created"
-
22
respond_to do |format|
-
22
format.html do
-
22
flash[:notice] = I18n.t(message_key)
-
22
redirect_to model
-
end
-
22
format.turbo_stream do
-
render_save_message_stream(
-
success: true,
-
message: I18n.t(message_key)
-
)
-
end
-
end
-
end
-
-
8
sig { params(model: ActiveRecord::Base, view: Symbol).void }
-
4
def handle_create_failure(model, view = :new)
-
10
respond_to do |format|
-
20
format.html { render view, status: :unprocessable_content }
-
10
format.turbo_stream do
-
render_save_message_stream(
-
success: false,
-
message: I18n.t("shared.messages.save_failed"),
-
model: model
-
)
-
end
-
end
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module UserActivityCheck
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
private
-
-
8
sig { void }
-
4
def require_user_active
-
349
then: 337
else: 12
return if current_user.is_active?
-
-
12
flash[:alert] = current_user.inactive_user_message
-
12
handle_inactive_user_redirect
-
end
-
-
# Override this method in controllers to provide custom redirect logic
-
4
sig { void }
-
4
def handle_inactive_user_redirect
-
raise NotImplementedError
-
end
-
end
-
4
class CredentialsController < ApplicationController
-
4
before_action :require_login
-
-
4
def create
-
5
create_options = WebAuthn::Credential.options_for_create(
-
user: {
-
id: current_user.webauthn_id,
-
name: current_user.email
-
},
-
exclude: current_user.credentials.pluck(:external_id),
-
authenticator_selection: {user_verification: "required"}
-
)
-
-
5
session[:current_registration] = {challenge: create_options.challenge}
-
-
5
respond_to do |format|
-
10
format.json { render json: create_options }
-
end
-
end
-
-
4
def callback
-
12
webauthn_credential = WebAuthn::Credential.from_create(params)
-
8
verify_and_save_credential(webauthn_credential)
-
rescue WebAuthn::Error => e
-
4
error_msg = I18n.t("credentials.messages.verification_failed")
-
4
render json: "#{error_msg}: #{e.message}",
-
status: :unprocessable_content
-
ensure
-
12
session.delete(:current_registration)
-
end
-
-
4
def destroy
-
4
credential = current_user.credentials.find(params[:id])
-
-
2
then: 1
if current_user.can_delete_credentials?
-
1
credential.destroy
-
1
flash[:notice] = I18n.t("credentials.messages.deleted")
-
else: 1
else
-
1
flash[:error] = I18n.t("credentials.messages.cannot_delete_last")
-
end
-
-
2
redirect_to change_settings_user_path(current_user)
-
end
-
-
4
private
-
-
4
def verify_and_save_credential(webauthn_credential)
-
8
challenge = session[:current_registration]["challenge"]
-
8
webauthn_credential.verify(challenge, user_verification: true)
-
-
8
credential = current_user.credentials.find_or_initialize_by(
-
external_id: Base64.strict_encode64(webauthn_credential.raw_id)
-
)
-
-
8
credential_attrs = credential_params(webauthn_credential)
-
# Ensure user_id is set for new records
-
8
then: 7
else: 1
credential_attrs[:user_id] = current_user.id if credential.new_record?
-
-
8
then: 5
if credential.update(credential_attrs)
-
5
render json: {status: "ok"}, status: :ok
-
else: 3
else
-
3
error_msg = I18n.t("credentials.messages.could_not_add")
-
3
render json: error_msg, status: :unprocessable_content
-
end
-
end
-
-
4
def credential_params(webauthn_credential)
-
{
-
8
nickname: params[:credential_nickname],
-
public_key: webauthn_credential.public_key,
-
sign_count: webauthn_credential.sign_count
-
}
-
end
-
end
-
4
class EnclosedAssessmentsController < ApplicationController
-
4
include AssessmentController
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class ErrorsController < ApplicationController
-
4
skip_before_action :require_login
-
4
skip_before_action :update_last_active_at
-
-
4
def not_found
-
1
capture_exception_for_sentry
-
-
1
respond_to do |format|
-
2
format.html { render status: :not_found }
-
1
format.json do
-
render json: {error: I18n.t("errors.not_found.title")},
-
status: :not_found
-
end
-
1
format.any { head :not_found }
-
end
-
end
-
-
4
def internal_server_error
-
1
capture_exception_for_sentry
-
-
1
respond_to do |format|
-
2
format.html { render status: :internal_server_error }
-
1
format.json do
-
render json: {error: I18n.t("errors.internal_server_error.title")},
-
status: :internal_server_error
-
end
-
1
format.any { head :internal_server_error }
-
end
-
end
-
-
4
private
-
-
4
def capture_exception_for_sentry
-
2
else: 0
then: 2
return unless Rails.env.production?
-
-
exception = request.env["action_dispatch.exception"]
-
then: 0
else: 0
Sentry.capture_exception(exception) if exception
-
end
-
end
-
4
class FanAssessmentsController < ApplicationController
-
4
include AssessmentController
-
end
-
4
class GuidesController < ApplicationController
-
4
skip_before_action :require_login
-
-
4
def index
-
@guides = collect_guides
-
end
-
-
4
def show
-
guide_path = params[:path]
-
metadata_file = guide_screenshots_root.join(guide_path, "metadata.json")
-
-
then: 0
if metadata_file.exist?
-
@guide_data = JSON.parse(metadata_file.read)
-
@guide_path = guide_path
-
@guide_title = humanize_guide_title(guide_path)
-
else: 0
else
-
redirect_to guides_path, alert: I18n.t("guides.messages.not_found")
-
end
-
end
-
-
4
private
-
-
4
def guide_screenshots_root
-
Rails.public_path.join("guide_screenshots")
-
end
-
-
4
def collect_guides
-
guides = []
-
-
# Find all metadata.json files
-
Dir.glob(guide_screenshots_root.join("**", "metadata.json")).each do |metadata_path|
-
relative_path = Pathname.new(metadata_path).relative_path_from(guide_screenshots_root).dirname.to_s
-
metadata = JSON.parse(File.read(metadata_path))
-
-
guides << {
-
path: relative_path,
-
title: humanize_guide_title(relative_path),
-
screenshot_count: metadata["screenshots"].size,
-
updated_at: metadata["updated_at"],
-
first_screenshot: metadata["screenshots"].first
-
}
-
end
-
-
guides.sort_by { |g| g[:title] }
-
end
-
-
4
def humanize_guide_title(path)
-
# Convert spec/features/inspections/inspection_creation_workflow_spec to "Inspection Creation Workflow"
-
path.split("/").last.gsub(/_spec$/, "").humanize
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class InspectionsController < ApplicationController
-
4
extend T::Sig
-
-
4
include InspectionTurboStreams
-
4
include PublicViewable
-
4
include UserActivityCheck
-
-
4
skip_before_action :require_login, only: %i[show]
-
4
before_action :check_assessments_enabled
-
4
before_action :set_inspection, except: %i[create index]
-
4
before_action :check_inspection_owner, except: %i[create index show]
-
4
before_action :validate_unit_ownership, only: %i[update]
-
4
before_action :redirect_if_complete,
-
except: %i[create index destroy mark_draft show log]
-
4
before_action :require_user_active, only: %i[create edit update]
-
4
before_action :validate_inspection_completability, only: %i[show edit]
-
4
before_action :no_index
-
-
4
def index
-
425
all_inspections = filtered_inspections_query_without_order.to_a
-
425
partition_inspections(all_inspections)
-
-
425
@title = build_index_title
-
425
@has_any_inspections = all_inspections.any?
-
-
425
respond_to do |format|
-
425
format.html
-
425
format.csv do
-
9
log_inspection_event("exported", nil, "Exported #{@complete_inspections.count} inspections to CSV")
-
9
send_inspections_csv
-
end
-
end
-
end
-
-
4
def show
-
# Handle federation HEAD requests
-
153
then: 1
else: 152
return head :ok if request.head?
-
-
152
respond_to do |format|
-
250
format.html { render_show_html }
-
181
format.pdf { send_inspection_pdf }
-
159
format.png { send_inspection_qr_code }
-
152
format.json do
-
18
render json: InspectionBlueprint.render(@inspection)
-
end
-
end
-
end
-
-
4
def create
-
20
unit_id = params[:unit_id] || params.dig(:inspection, :unit_id)
-
20
result = InspectionCreationService.new(
-
current_user,
-
unit_id: unit_id
-
).create
-
-
20
then: 18
if result[:success]
-
18
log_inspection_event("created", result[:inspection])
-
18
flash[:notice] = result[:message]
-
18
redirect_to edit_inspection_path(result[:inspection])
-
else: 2
else
-
2
flash[:alert] = result[:message]
-
2
redirect_to result[:redirect_path]
-
end
-
end
-
-
4
def edit
-
181
validate_tab_parameter
-
181
set_previous_inspection
-
end
-
-
4
def update
-
32
previous_attributes = @inspection.attributes.dup
-
-
32
params_to_update = inspection_params
-
-
32
then: 0
else: 32
if @image_processing_error
-
flash.now[:alert] = @image_processing_error.message
-
render :edit, status: :unprocessable_content
-
return
-
end
-
-
32
then: 30
if @inspection.update(params_to_update)
-
30
changed_data = calculate_changes(
-
previous_attributes,
-
@inspection.attributes,
-
inspection_params.keys
-
)
-
30
log_inspection_event("updated", @inspection, nil, changed_data)
-
30
handle_successful_update
-
else: 2
else
-
2
handle_failed_update
-
end
-
end
-
-
4
def destroy
-
6
then: 2
else: 4
if @inspection.complete?
-
2
alert_message = I18n.t("inspections.messages.delete_complete_denied")
-
2
redirect_to inspection_path(@inspection), alert: alert_message
-
2
return
-
end
-
-
# Capture inspection details before deletion for the audit log
-
inspection_details = {
-
4
inspection_date: @inspection.inspection_date,
-
then: 4
else: 0
unit_serial: @inspection.unit&.serial,
-
then: 4
else: 0
unit_name: @inspection.unit&.name,
-
complete_date: @inspection.complete_date
-
}
-
-
4
@inspection.destroy
-
# Log the deletion with the inspection details in metadata
-
4
Event.log(
-
user: current_user,
-
action: "deleted",
-
resource: @inspection,
-
details: nil,
-
metadata: inspection_details
-
)
-
4
redirect_to inspections_path, notice: I18n.t("inspections.messages.deleted")
-
end
-
-
4
def select_unit
-
19
@units = current_user.units
-
.includes(photo_attachment: :blob)
-
.search(params[:search])
-
.by_manufacturer(params[:manufacturer])
-
.order(:name)
-
19
@title = t("inspections.titles.select_unit")
-
-
19
render :select_unit
-
end
-
-
4
def update_unit
-
6
unit = current_user.units.find_by(id: params[:unit_id])
-
-
6
else: 4
then: 2
unless unit
-
2
flash[:alert] = t("inspections.errors.invalid_unit")
-
2
redirect_to select_unit_inspection_path(@inspection) and return
-
end
-
-
4
@inspection.unit = unit
-
-
4
then: 4
if @inspection.save
-
4
handle_successful_unit_update(unit)
-
else: 0
else
-
handle_failed_unit_update
-
end
-
end
-
-
4
def handle_successful_unit_update(unit)
-
4
log_inspection_event("unit_changed", @inspection, "Unit changed to #{unit.name}")
-
4
flash[:notice] = t("inspections.messages.unit_changed", unit_name: unit.name)
-
4
redirect_to edit_inspection_path(@inspection)
-
end
-
-
4
def handle_failed_unit_update
-
error_messages = @inspection.errors.full_messages.join(", ")
-
flash[:alert] = t("inspections.messages.unit_change_failed", errors: error_messages)
-
redirect_to select_unit_inspection_path(@inspection)
-
end
-
-
4
def complete
-
8
validation_errors = @inspection.validate_completeness
-
-
8
then: 1
else: 7
if validation_errors.any?
-
1
error_list = validation_errors.join(", ")
-
1
flash[:alert] =
-
t("inspections.messages.cannot_complete", errors: error_list)
-
1
redirect_to edit_inspection_path(@inspection)
-
1
return
-
end
-
-
7
@inspection.complete!(current_user)
-
6
log_inspection_event("completed", @inspection)
-
6
flash[:notice] = t("inspections.messages.marked_complete")
-
6
redirect_to @inspection
-
end
-
-
4
def mark_draft
-
6
then: 5
if @inspection.update(complete_date: nil)
-
5
log_inspection_event("marked_draft", @inspection)
-
5
flash[:notice] = t("inspections.messages.marked_in_progress")
-
else: 1
else
-
1
error_messages = @inspection.errors.full_messages.join(", ")
-
1
flash[:alert] = t("inspections.messages.mark_in_progress_failed",
-
errors: error_messages)
-
end
-
6
redirect_to edit_inspection_path(@inspection)
-
end
-
-
4
def log
-
3
@events = Event.for_resource(@inspection).recent.includes(:user)
-
3
@title = I18n.t("inspections.titles.log", inspection: @inspection.id)
-
end
-
-
4
def inspection_params
-
98
base_params = build_base_params
-
98
add_assessment_params(base_params)
-
-
98
process_image_params(base_params, :photo_1, :photo_2, :photo_3)
-
end
-
-
4
private
-
-
4
def check_assessments_enabled
-
903
else: 903
then: 0
head :not_found unless ENV["HAS_ASSESSMENTS"] == "true"
-
end
-
-
4
def partition_inspections(all_inspections)
-
425
@draft_inspections = all_inspections
-
122
.select { |inspection| inspection.complete_date.nil? }
-
.sort_by(&:created_at)
-
-
425
@complete_inspections = all_inspections
-
122
.select { |inspection| inspection.complete_date.present? }
-
64
.sort_by { |inspection| -inspection.created_at.to_i }
-
end
-
-
4
def send_inspections_csv
-
9
csv_data = InspectionCsvExportService.new(@complete_inspections).generate
-
8
filename = I18n.t("inspections.export.csv_filename", date: Time.zone.today)
-
8
send_data csv_data, filename: filename
-
end
-
-
4
def validate_tab_parameter
-
181
then: 108
else: 73
return if params[:tab].blank?
-
-
73
valid_tabs = helpers.inspection_tabs(@inspection)
-
73
then: 72
else: 1
return if valid_tabs.include?(params[:tab])
-
-
1
redirect_to edit_inspection_path(@inspection),
-
alert: I18n.t("inspections.messages.invalid_tab")
-
end
-
-
4
def validate_inspection_completability
-
336
else: 81
then: 255
return unless @inspection.complete?
-
81
then: 79
else: 2
return if @inspection.can_mark_complete?
-
-
2
error_message = I18n.t(
-
"inspections.errors.invalid_completion_state",
-
errors: @inspection.completion_errors.join(", ")
-
)
-
-
2
inspection_errors = @inspection.completion_errors
-
2
Rails.logger.error "Inspection #{@inspection.id} is marked complete " \
-
"but has errors: #{inspection_errors}"
-
-
# Only raise error in development/test environments
-
2
then: 2
if Rails.env.local?
-
2
test_message = "In tests, use create(:inspection, :completed) to avoid this."
-
2
raise StandardError, "DATA INTEGRITY ERROR: #{error_message}. #{test_message}"
-
else
-
else: 0
# In production, log the error but continue
-
Rails.logger.error "DATA INTEGRITY ERROR: #{error_message}"
-
end
-
end
-
-
4
ASSESSMENT_SYSTEM_ATTRIBUTES = %w[
-
inspection_id
-
created_at
-
updated_at
-
].freeze
-
-
# Build safe mappings from Inspection::ALL_ASSESSMENT_TYPES
-
# This ensures mappings stay in sync with the model definition
-
4
ASSESSMENT_TAB_MAPPING = Inspection::ALL_ASSESSMENT_TYPES
-
.each_with_object({}) do |(method_name, _), hash|
-
# Convert :user_height_assessment to "user_height"
-
28
tab_name = method_name.to_s.gsub(/_assessment$/, "")
-
28
hash[tab_name] = method_name
-
end.freeze
-
-
4
ASSESSMENT_CLASS_MAPPING = Inspection::ALL_ASSESSMENT_TYPES
-
.each_with_object({}) do |(method_name, klass), hash|
-
# Convert :user_height_assessment to "user_height"
-
28
tab_name = method_name.to_s.gsub(/_assessment$/, "")
-
28
hash[tab_name] = klass
-
end.freeze
-
-
4
def build_base_params
-
98
params.require(:inspection).permit(*Inspection::USER_EDITABLE_PARAMS)
-
end
-
-
4
def add_assessment_params(base_params)
-
98
Inspection::ALL_ASSESSMENT_TYPES.each_key do |ass_type|
-
686
ass_key = "#{ass_type}_attributes"
-
686
then: 686
else: 0
next if params[:inspection][ass_key].blank?
-
-
ass_params = params[:inspection][ass_key]
-
permitted_ass_params = assessment_permitted_attributes(ass_type)
-
base_params[ass_key] = ass_params.permit(*permitted_ass_params)
-
end
-
end
-
-
4
def assessment_permitted_attributes(assessment_type)
-
model_class = "Assessments::#{assessment_type.to_s.camelize}".constantize
-
model_class.column_name_syms - ASSESSMENT_SYSTEM_ATTRIBUTES
-
end
-
-
4
def filtered_inspections_query_without_order = current_user.inspections
-
.includes(:inspector_company, unit: {photo_attachment: {blob: :attachments}})
-
.search(params[:query])
-
.filter_by_result(params[:result])
-
.filter_by_unit(params[:unit_id])
-
.filter_by_operator(params[:operator])
-
-
4
def no_index = response.set_header("X-Robots-Tag", "noindex,nofollow")
-
-
4
def set_inspection
-
452
@inspection = Inspection
-
.includes(
-
:user, :inspector_company,
-
*Inspection::ALL_ASSESSMENT_TYPES.keys,
-
unit: {photo_attachment: :blob},
-
photo_1_attachment: :blob,
-
photo_2_attachment: :blob,
-
photo_3_attachment: :blob
-
)
-
then: 452
else: 0
.find_by(id: params[:id]&.upcase)
-
-
452
else: 437
then: 15
head :not_found unless @inspection
-
end
-
-
4
def check_inspection_owner
-
282
then: 273
else: 9
return if current_user && @inspection.user_id == current_user.id
-
-
9
head :not_found
-
end
-
-
4
def redirect_if_complete
-
257
else: 10
then: 247
return unless @inspection.complete?
-
-
10
flash[:notice] = I18n.t("inspections.messages.cannot_edit_complete")
-
10
redirect_to @inspection
-
end
-
-
4
def build_index_title
-
425
title = I18n.t("inspections.titles.index")
-
425
else: 10
then: 415
return title unless params[:result]
-
-
10
in: 8
status = case params[:result]
-
8
in: 2
in "passed" then I18n.t("inspections.status.passed")
-
2
else: 0
in "failed" then I18n.t("inspections.status.failed")
-
else params[:result]
-
end
-
10
"#{title} - #{status}"
-
end
-
-
4
def validate_unit_ownership
-
35
else: 1
then: 34
return unless inspection_params[:unit_id]
-
-
1
unit = current_user.units.find_by(id: inspection_params[:unit_id])
-
1
then: 0
else: 1
return if unit
-
-
# Unit ID not found or doesn't belong to user - security issue
-
1
flash[:alert] = I18n.t("inspections.errors.invalid_unit")
-
1
render :edit, status: :unprocessable_content
-
end
-
-
4
def handle_successful_update
-
30
respond_to do |format|
-
30
format.html do
-
17
flash[:notice] = I18n.t("inspections.messages.updated")
-
17
redirect_to @inspection
-
end
-
30
format.json do
-
3
render json: {status: I18n.t("shared.api.success"),
-
inspection: @inspection}
-
end
-
40
format.turbo_stream { render turbo_stream: success_turbo_streams }
-
end
-
end
-
-
4
def handle_failed_update
-
2
respond_to do |format|
-
2
format.html { render :edit, status: :unprocessable_content }
-
3
format.json { render json: {status: I18n.t("shared.api.error"), errors: @inspection.errors.full_messages} }
-
3
format.turbo_stream { render turbo_stream: error_turbo_streams }
-
end
-
end
-
-
4
def send_inspection_pdf
-
29
result = PdfCacheService.fetch_or_generate_inspection_pdf(
-
@inspection,
-
debug_enabled: admin_debug_enabled?,
-
debug_queries: debug_sql_queries
-
)
-
29
@inspection.update(pdf_last_accessed_at: Time.current)
-
-
29
handle_pdf_response(result, pdf_filename)
-
end
-
-
4
def send_inspection_qr_code
-
7
qr_code_png = QrCodeService.generate_qr_code(@inspection)
-
-
7
send_data qr_code_png,
-
filename: qr_code_filename,
-
type: "image/png",
-
disposition: "inline"
-
end
-
-
# PublicViewable implementation
-
4
def check_resource_owner
-
check_inspection_owner
-
end
-
-
4
def owns_resource?
-
87
@inspection && current_user && @inspection.user_id == current_user.id
-
end
-
-
4
def pdf_filename
-
42
then: 42
else: 0
identifier = @inspection.unit&.serial || @inspection.id
-
42
I18n.t("inspections.export.pdf_filename", identifier: identifier)
-
end
-
-
4
def qr_code_filename
-
7
then: 7
else: 0
identifier = @inspection.unit&.serial || @inspection.id
-
7
I18n.t("inspections.export.qr_filename", identifier: identifier)
-
end
-
-
4
def resource_pdf_url
-
13
inspection_path(@inspection, format: :pdf)
-
end
-
-
4
def handle_inactive_user_redirect
-
7
then: 6
if action_name == "create"
-
6
unit_id = params[:unit_id] || params.dig(:inspection, :unit_id)
-
6
then: 4
if unit_id.present?
-
4
unit = current_user.units.find_by(id: unit_id)
-
4
then: 4
else: 0
redirect_to unit ? unit_path(unit) : inspections_path
-
else: 2
else
-
2
redirect_to inspections_path
-
else: 1
end
-
1
then: 1
elsif action_name.in?(%w[edit update]) && @inspection
-
1
redirect_to inspection_path(@inspection)
-
else: 0
else
-
redirect_to inspections_path
-
end
-
end
-
-
4
NOT_COPIED_FIELDS = %i[
-
complete_date
-
created_at
-
id
-
inspection_date
-
inspection_id
-
inspector_company_id
-
is_seed
-
passed
-
pdf_last_accessed_at
-
unit_id
-
updated_at
-
user_id
-
].freeze
-
-
4
def set_previous_inspection
-
181
then: 177
else: 4
@previous_inspection = @inspection.unit&.last_inspection
-
181
then: 156
else: 25
return if !@previous_inspection || @previous_inspection.id == @inspection.id
-
-
25
@prefilled_fields = []
-
25
current_object, previous_object, column_name_syms = get_prefill_objects
-
-
25
column_name_syms.each do |field|
-
498
then: 173
else: 325
next if NOT_COPIED_FIELDS.include?(field)
-
325
then: 325
else: 0
then: 39
else: 286
next if previous_object&.send(field).nil?
-
286
else: 188
then: 98
next unless current_object.send(field).nil?
-
-
188
@prefilled_fields << translate_field_name(field)
-
end
-
end
-
-
4
def get_prefill_objects
-
25
case params[:tab]
-
when: 13
when "inspection", "", nil
-
13
[@inspection, @previous_inspection, Inspection.column_name_syms]
-
when "results"
-
# Results tab uses inspection fields directly, not an assessment
-
# Include all fields shown on results tab: passed, risk_assessment, and photos
-
when: 3
# NOT_COPIED_FIELDS will filter out fields that shouldn't be prefilled
-
3
results_fields = [:passed, :risk_assessment, :photo_1, :photo_2, :photo_3]
-
3
[@inspection, @previous_inspection, results_fields]
-
else: 9
else
-
9
assessment_method = ASSESSMENT_TAB_MAPPING[params[:tab]]
-
9
assessment_class = ASSESSMENT_CLASS_MAPPING[params[:tab]]
-
[
-
9
@inspection.public_send(assessment_method),
-
@previous_inspection.public_send(assessment_method),
-
assessment_class.column_name_syms
-
]
-
end
-
end
-
-
8
sig { params(field: Symbol).returns String }
-
4
def translate_field_name(field)
-
188
is_comment = ChobbleForms::FieldUtils.is_comment_field?(field)
-
188
is_pass = ChobbleForms::FieldUtils.is_pass_field?(field)
-
188
field_base = ChobbleForms::FieldUtils.strip_field_suffix(field)
-
188
tab_name = params[:tab] || :inspection
-
188
i18n_base = "forms.#{tab_name}.fields"
-
-
188
translated = I18n.t("#{i18n_base}.#{field_base}", default: nil)
-
188
translated ||= I18n.t("#{i18n_base}.#{field}")
-
-
188
then: 73
if is_comment
-
73
else: 115
translated += " (#{I18n.t("shared.comment")})"
-
115
then: 59
else: 56
elsif is_pass
-
59
translated += " (#{I18n.t("shared.pass")}/#{I18n.t("shared.fail")})"
-
end
-
-
188
translated
-
end
-
-
4
def log_inspection_event(action, inspection, details = nil, changed_data = nil)
-
72
else: 72
then: 0
return unless current_user
-
-
72
then: 63
if inspection
-
63
Event.log(
-
user: current_user,
-
action: action,
-
resource: inspection,
-
details: details,
-
changed_data: changed_data
-
)
-
else
-
else: 9
# For events without a specific inspection (like CSV export)
-
9
Event.log_system_event(
-
user: current_user,
-
action: action,
-
details: details,
-
metadata: {resource_type: "Inspection"}
-
)
-
end
-
rescue => e
-
Rails.logger.error "Failed to log inspection event: #{e.message}"
-
end
-
-
4
def calculate_changes(previous_attributes, current_attributes, changed_keys)
-
30
changes = {}
-
-
30
changed_keys.map(&:to_s).each do |key|
-
81
previous_value = previous_attributes[key]
-
81
current_value = current_attributes[key]
-
-
81
else: 35
then: 46
next unless previous_value != current_value
-
-
35
changes[key] = {
-
"from" => previous_value,
-
"to" => current_value
-
}
-
end
-
-
30
changes.presence
-
end
-
end
-
4
class InspectorCompaniesController < ApplicationController
-
4
include TurboStreamResponders
-
-
4
before_action :set_inspector_company, only: %i[
-
show edit update
-
]
-
4
before_action :require_login
-
4
before_action :require_admin, except: [:show]
-
-
4
def index
-
13
@inspector_companies = InspectorCompany
-
.with_attached_logo
-
.by_status(params[:active])
-
.search_by_term(params[:search])
-
.order(:name)
-
end
-
-
4
def show
-
13
@company_stats = @inspector_company.company_statistics
-
13
@recent_inspections = @inspector_company.recent_inspections(5)
-
end
-
-
4
def new
-
13
@inspector_company = InspectorCompany.new
-
13
@inspector_company.country = "UK"
-
end
-
-
4
def create
-
16
@inspector_company = InspectorCompany.new(inspector_company_params)
-
-
16
then: 12
if @inspector_company.save
-
12
handle_create_success(@inspector_company)
-
else: 4
else
-
4
handle_create_failure(@inspector_company)
-
end
-
end
-
-
4
def edit
-
end
-
-
4
def update
-
11
then: 7
if @inspector_company.update(inspector_company_params)
-
7
handle_update_success(@inspector_company)
-
else: 4
else
-
4
handle_update_failure(@inspector_company)
-
end
-
end
-
-
4
private
-
-
4
def set_inspector_company
-
40
@inspector_company = InspectorCompany.find(params[:id])
-
end
-
-
4
def inspector_company_params
-
27
params.require(:inspector_company).permit(
-
:name, :email, :phone, :address,
-
:city, :postal_code, :country,
-
:active, :notes, :logo
-
)
-
end
-
end
-
4
class MaterialsAssessmentsController < ApplicationController
-
4
include AssessmentController
-
end
-
4
class PagesController < ApplicationController
-
4
include TurboStreamResponders
-
-
4
skip_before_action :require_login, only: :show
-
4
before_action :require_admin, except: :show
-
4
before_action :set_page, only: %i[edit update destroy]
-
-
4
def index
-
2
@pages = Page.order(:slug)
-
end
-
-
4
def show
-
85
slug = params[:slug] || "/"
-
85
@page = Page.pages.find_by(slug: slug)
-
-
# If homepage doesn't exist, create a temporary empty page object
-
85
then: 45
if @page.nil? && slug == "/"
-
45
@page = Page.new(
-
slug: "/",
-
content: "",
-
meta_title: "play-test",
-
meta_description: ""
-
else: 40
)
-
40
else: 39
elsif @page.nil?
-
then: 1
# For other missing pages, still raise not found
-
1
raise ActiveRecord::RecordNotFound
-
end
-
end
-
-
4
def new
-
1
@page = Page.new
-
end
-
-
4
def create
-
4
@page = Page.new(page_params)
-
4
then: 2
if @page.save
-
2
handle_create_success(@page)
-
else: 2
else
-
2
handle_create_failure(@page)
-
end
-
end
-
-
4
def edit
-
end
-
-
4
def update
-
3
then: 2
if @page.update(page_params)
-
2
handle_update_success(@page)
-
else: 1
else
-
1
handle_update_failure(@page)
-
end
-
end
-
-
4
def destroy
-
2
@page.destroy
-
2
redirect_to pages_path, notice: I18n.t("pages.messages.destroyed")
-
end
-
-
4
private
-
-
4
def set_page
-
6
@page = Page.find_by!(slug: params[:id])
-
end
-
-
4
def page_params
-
7
params.require(:page).permit(
-
:slug,
-
:meta_title,
-
:meta_description,
-
:link_title,
-
:content,
-
:is_snippet
-
)
-
end
-
end
-
# typed: strict
-
-
4
class SafetyStandardsController < ApplicationController
-
4
extend T::Sig
-
-
4
skip_before_action :require_login
-
4
skip_before_action :verify_authenticity_token, only: [:index]
-
-
4
CALCULATION_TYPES = T.let(%w[anchors slide_runout wall_height user_capacity].freeze, T::Array[String])
-
-
4
API_EXAMPLE_PARAMS = T.let({
-
anchors: {
-
type: "anchors",
-
length: 5.0,
-
width: 5.0,
-
height: 3.0
-
},
-
slide_runout: {
-
type: "slide_runout",
-
platform_height: 2.5
-
},
-
wall_height: {
-
type: "wall_height",
-
platform_height: 2.0,
-
user_height: 1.5
-
},
-
user_capacity: {
-
type: "user_capacity",
-
length: 10.0,
-
width: 8.0,
-
negative_adjustment_area: 15.0,
-
max_user_height: 1.5
-
}
-
}.freeze, T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
-
-
4
API_EXAMPLE_RESPONSES = T.let({
-
anchors: {
-
passed: true,
-
status: "Calculation completed successfully",
-
result: {
-
value: 8,
-
value_suffix: "",
-
breakdown: [
-
["Front/back area", "5.0m (W) × 3.0m (H) = 15.0m²"],
-
["Sides area", "5.0m (L) × 3.0m (H) = 15.0m²"],
-
["Front & back anchor counts", "((15.0 × 114.0 * 1.5) ÷ 1600.0 = 2"],
-
["Left & right anchor counts", "((15.0 × 114.0 * 1.5) ÷ 1600.0 = 2"],
-
["Calculated total anchors", "(2 + 2) × 2 = 8"]
-
]
-
}
-
},
-
slide_runout: {
-
passed: true,
-
status: "Calculation completed successfully",
-
result: {
-
value: 1.25,
-
value_suffix: "m",
-
breakdown: [
-
["50% calculation", "2.5m × 0.5 = 1.25m"],
-
["Minimum requirement", "0.3m (300mm)"],
-
["Base runout", "Maximum of 1.25m and 0.3m = 1.25m"]
-
]
-
}
-
},
-
wall_height: {
-
passed: true,
-
status: "Calculation completed successfully",
-
result: {
-
value: 1.5,
-
value_suffix: "m",
-
breakdown: [
-
["Height range", "0.6m - 3.0m"],
-
["Calculation", "1.5m (user height)"]
-
]
-
}
-
},
-
user_capacity: {
-
passed: true,
-
status: "Calculation completed successfully",
-
result: {
-
length: 10.0,
-
width: 8.0,
-
area: 80.0,
-
negative_adjustment_area: 15.0,
-
usable_area: 65.0,
-
max_user_height: 1.5,
-
capacities: {
-
users_1000mm: 65,
-
users_1200mm: 48,
-
users_1500mm: 39,
-
users_1800mm: 0
-
},
-
breakdown: [
-
["Total area", "10m × 8m = 80m²"],
-
["Obstacles/adjustments", "- 15m²"],
-
["Usable area", "65m²"],
-
["Capacity calculations", "Based on usable area"],
-
["1m users", "65 ÷ 1 = 65 users"],
-
["1.2m users", "65 ÷ 1.3 = 48 users"],
-
["1.5m users", "65 ÷ 1.7 = 39 users"],
-
["1.8m users", "Not allowed (exceeds height limit)"]
-
]
-
}
-
}
-
}.freeze, T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
-
-
8
sig { void }
-
4
def index
-
128
@calculation_metadata = calculation_metadata
-
-
128
then: 80
if post_request_with_calculation?
-
80
handle_calculation_post
-
else: 48
else
-
48
handle_calculation_get
-
end
-
end
-
-
4
private
-
-
8
sig { returns(T::Boolean) }
-
4
def post_request_with_calculation?
-
128
request.post? && params[:calculation].present?
-
end
-
-
8
sig { void }
-
4
def handle_calculation_post
-
80
type = params[:calculation][:type]
-
80
then: 70
else: 10
calculate_safety_standard if CALCULATION_TYPES.include?(type)
-
-
80
respond_to do |format|
-
80
format.turbo_stream
-
120
format.json { render json: build_json_response }
-
96
format.html { redirect_with_calculation_params }
-
end
-
end
-
-
8
sig { void }
-
4
def handle_calculation_get
-
48
then: 17
else: 31
if params[:calculation].present?
-
17
type = params[:calculation][:type]
-
17
then: 16
else: 1
calculate_safety_standard if CALCULATION_TYPES.include?(type)
-
end
-
-
48
respond_to do |format|
-
48
format.html
-
end
-
end
-
-
7
sig { void }
-
4
def redirect_with_calculation_params
-
16
redirect_to safety_standards_path(
-
calculation: params[:calculation].to_unsafe_h
-
)
-
end
-
-
8
sig { void }
-
4
def calculate_safety_standard
-
116
type = params[:calculation][:type]
-
-
116
else: 0
case type
-
when: 56
when "anchors"
-
56
calculate_anchors
-
when: 23
when "slide_runout"
-
23
calculate_slide_runout
-
when: 25
when "wall_height"
-
25
calculate_wall_height
-
when: 12
when "user_capacity"
-
12
calculate_user_capacity
-
end
-
end
-
-
8
sig { void }
-
4
def calculate_anchors
-
56
dimensions = extract_dimensions(:length, :width, :height)
-
-
56
then: 41
if dimensions.values.all?(&:positive?)
-
41
@anchors_result = EN14960.calculate_anchors(**dimensions)
-
else: 15
else
-
15
set_error(:anchors, :invalid_dimensions)
-
end
-
end
-
-
8
sig { void }
-
4
def calculate_slide_runout
-
23
height = param_to_float(:platform_height)
-
23
has_stop_wall = params[:calculation][:has_stop_wall] == "1"
-
-
23
then: 18
if height.positive?
-
18
@slide_runout_result = build_runout_result(height, has_stop_wall)
-
else: 5
else
-
5
set_error(:slide_runout, :invalid_height)
-
end
-
end
-
-
8
sig { void }
-
4
def calculate_wall_height
-
25
platform_height = param_to_float(:platform_height)
-
25
user_height = param_to_float(:user_height)
-
-
25
then: 19
if platform_height.positive? && user_height.positive?
-
19
@wall_height_result = build_wall_height_result(
-
platform_height, user_height
-
)
-
else: 6
else
-
6
set_error(:wall_height, :invalid_height)
-
end
-
end
-
-
5
sig { void }
-
4
def calculate_user_capacity
-
12
length = param_to_float(:length)
-
12
width = param_to_float(:width)
-
12
max_user_height = param_to_float(:max_user_height)
-
12
then: 2
else: 10
max_user_height = nil if max_user_height.zero?
-
12
negative_adjustment_area = param_to_float(:negative_adjustment_area)
-
-
12
then: 8
if length.positive? && width.positive?
-
8
@user_capacity_result = EN14960.calculate_user_capacity(
-
length, width, max_user_height, negative_adjustment_area
-
)
-
else: 4
else
-
4
set_error(:user_capacity, :invalid_dimensions)
-
end
-
end
-
-
8
sig { params(keys: Symbol).returns(T::Hash[Symbol, Float]) }
-
4
def extract_dimensions(*keys)
-
224
keys.index_with { |key| param_to_float(key) }
-
end
-
-
8
sig { params(key: Symbol).returns(Float) }
-
4
def param_to_float(key)
-
305
params[:calculation][key].to_f
-
end
-
-
8
sig { params(type: Symbol, error_key: Symbol).void }
-
4
def set_error(type, error_key)
-
30
error_msg = t("safety_standards.errors.#{error_key}")
-
30
instance_variable_set("@#{type}_error", error_msg)
-
30
instance_variable_set("@#{type}_result", nil)
-
end
-
-
7
sig { params(platform_height: Float, has_stop_wall: T::Boolean).returns(T.untyped) }
-
4
def build_runout_result(platform_height, has_stop_wall)
-
18
EN14960.calculate_slide_runout(
-
platform_height,
-
has_stop_wall: has_stop_wall
-
)
-
end
-
-
7
sig { params(platform_height: Float, user_height: Float).returns(T.untyped) }
-
4
def build_wall_height_result(platform_height, user_height)
-
# Workaround for EN14960 v0.4.0 bug where it returns Integer 0 instead of Float 0.0
-
# when platform_height < 0.6
-
begin
-
19
EN14960.calculate_wall_height(
-
platform_height, user_height
-
)
-
rescue TypeError => e
-
2
if e.message.include?("got type Integer with value 0")
-
then: 2
# Return the expected response for no walls required
-
2
EN14960::CalculatorResponse.new(
-
value: 0.0,
-
value_suffix: "m",
-
breakdown: [["No walls required", "Platform height < 0.6m"]]
-
)
-
else: 0
else
-
raise e
-
end
-
end
-
end
-
-
7
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def build_json_response
-
40
type = params[:calculation][:type]
-
40
else: 30
then: 10
return invalid_type_response(type) unless CALCULATION_TYPES.include?(type)
-
-
30
build_typed_json_response(type)
-
end
-
-
6
sig { params(type: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
-
4
def invalid_type_response(type)
-
10
{
-
passed: false,
-
status: t("safety_standards.api.invalid_calculation_type",
-
type: type || t("safety_standards.api.none_provided")),
-
result: nil
-
}
-
end
-
-
4
sig { params(message: String).returns(T::Hash[Symbol, T.untyped]) }
-
4
def error_response(message)
-
{
-
passed: false,
-
status: t("safety_standards.api.calculation_failed", error: message),
-
result: nil
-
}
-
end
-
-
7
sig { params(type: String).returns(T::Hash[Symbol, T.untyped]) }
-
4
def build_typed_json_response(type)
-
30
calculate_safety_standard
-
-
30
result, error = get_calculation_results(type)
-
-
30
then: 19
if result
-
19
build_success_response(type, result)
-
else: 11
else
-
11
build_error_response(error)
-
end
-
end
-
-
7
sig { params(type: String).returns([T.nilable(T.untyped), T.nilable(String)]) }
-
4
def get_calculation_results(type)
-
30
result_var = "@#{type}_result"
-
30
error_var = "@#{type}_error"
-
-
30
result = instance_variable_get(result_var)
-
30
error = instance_variable_get(error_var)
-
-
30
[result, error]
-
end
-
-
7
sig { params(type: String, result: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
-
4
def build_success_response(type, result)
-
19
then: 4
json_result = if type == "user_capacity"
-
4
else: 15
build_user_capacity_json(result)
-
15
then: 15
elsif result.is_a?(EN14960::CalculatorResponse)
-
{
-
15
value: result.value,
-
value_suffix: result.value_suffix || "",
-
breakdown: result.breakdown
-
}
-
else: 0
else
-
result
-
end
-
-
19
{
-
passed: true,
-
status: t("safety_standards.api.calculation_success"),
-
result: json_result
-
}
-
end
-
-
7
sig { params(error: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
-
4
def build_error_response(error)
-
11
{
-
passed: false,
-
status: error || t("safety_standards.api.unknown_error"),
-
result: nil
-
}
-
end
-
-
5
sig { params(result: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
-
4
def build_user_capacity_json(result)
-
4
else: 4
then: 0
return result unless result.is_a?(EN14960::CalculatorResponse)
-
-
4
length = param_to_float(:length)
-
4
width = param_to_float(:width)
-
4
max_user_height = param_to_float(:max_user_height)
-
4
then: 0
else: 4
max_user_height = nil if max_user_height.zero?
-
4
neg_adj = param_to_float(:negative_adjustment_area)
-
-
{
-
4
length: length,
-
width: width,
-
4
area: (length * width).round(2),
-
negative_adjustment_area: neg_adj,
-
4
usable_area: [(length * width) - neg_adj, 0].max.round(2),
-
max_user_height: max_user_height,
-
capacities: result.value,
-
breakdown: result.breakdown
-
}
-
end
-
-
8
sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) }
-
4
def calculation_metadata
-
{
-
128
anchors: anchor_metadata,
-
slide_runout: slide_runout_metadata,
-
wall_height: wall_height_metadata,
-
user_capacity: user_capacity_metadata
-
}
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def anchor_metadata
-
{
-
128
title: t("safety_standards.metadata.anchor_title"),
-
description: t("safety_standards.calculators.anchor.description"),
-
method_name: :calculate_required_anchors,
-
module_name: EN14960::Calculators::AnchorCalculator,
-
example_input: 25.0,
-
input_unit: "m²",
-
output_unit: "anchors",
-
formula_text: t("safety_standards.metadata.anchor_formula"),
-
standard_reference: t("safety_standards.metadata.standard_reference")
-
}
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def slide_runout_metadata
-
{
-
128
title: t("safety_standards.metadata.slide_runout_title"),
-
description: t("safety_standards.metadata.slide_runout_description"),
-
method_name: :calculate_required_runout,
-
additional_methods: [:calculate_runout_value],
-
module_name: EN14960::Calculators::SlideCalculator,
-
example_input: 2.5,
-
input_unit: "m",
-
output_unit: "m",
-
formula_text: t("safety_standards.metadata.runout_formula"),
-
standard_reference: t("safety_standards.metadata.standard_reference")
-
}
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def wall_height_metadata
-
{
-
128
title: t("safety_standards.metadata.wall_height_title"),
-
description: t("safety_standards.metadata.wall_height_description"),
-
method_name: :meets_height_requirements?,
-
module_name: EN14960::Calculators::SlideCalculator,
-
example_input: {platform_height: 2.0, user_height: 1.5},
-
input_unit: "m",
-
output_unit: t("safety_standards.metadata.requirement_text_unit"),
-
formula_text: t("safety_standards.metadata.wall_height_formula"),
-
standard_reference: t("safety_standards.metadata.standard_reference")
-
}
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def user_capacity_metadata
-
{
-
128
title: t("safety_standards.metadata.user_capacity_title"),
-
description: t("safety_standards.calculators.user_capacity.description"),
-
method_name: :calculate,
-
module_name: EN14960::Calculators::UserCapacityCalculator,
-
example_input: {length: 10.0, width: 8.0},
-
input_unit: "m",
-
output_unit: "users",
-
formula_text: t("safety_standards.metadata.user_capacity_formula"),
-
standard_reference: t("safety_standards.metadata.standard_reference")
-
}
-
end
-
end
-
4
class SearchController < ApplicationController
-
4
skip_before_action :require_login
-
-
4
def index
-
6
@federated_sites = Federation.sites(request.host, current_user)
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class SessionsController < ApplicationController
-
4
include SessionManagement
-
4
skip_before_action :require_login,
-
only: [:new, :create, :destroy, :passkey, :passkey_callback]
-
4
before_action :require_logged_out,
-
only: [:new, :create, :passkey, :passkey_callback]
-
-
4
def new
-
end
-
-
4
def create
-
602
else: 602
then: 0
sleep(rand(0.5..1.0)) unless Rails.env.test?
-
-
602
email = params.dig(:session, :email)
-
602
password = params.dig(:session, :password)
-
-
602
then: 594
if (user = authenticate_user(email, password))
-
594
handle_successful_login(user)
-
else: 8
else
-
8
flash.now[:alert] = I18n.t("session.login.error")
-
8
render :new, status: :unprocessable_content
-
end
-
end
-
-
4
def destroy
-
22
terminate_current_session
-
22
log_out
-
22
flash[:notice] = I18n.t("session.logout.success")
-
22
redirect_to root_path
-
end
-
-
4
def passkey
-
# Get all credentials for this RP to help password managers
-
1
all_credentials = Credential.all.map do |cred|
-
{
-
id: cred.external_id,
-
type: "public-key"
-
}
-
end
-
-
# Initiate passkey authentication
-
1
get_options = WebAuthn::Credential.options_for_get(
-
user_verification: "required",
-
allow_credentials: all_credentials
-
)
-
-
1
session[:passkey_authentication] = {challenge: get_options.challenge}
-
-
1
render json: get_options
-
end
-
-
4
def passkey_callback
-
1
webauthn_credential = WebAuthn::Credential.from_get(params)
-
1
credential = find_credential(webauthn_credential)
-
-
1
then: 0
if credential
-
verify_and_sign_in_with_passkey(credential, webauthn_credential)
-
else: 1
else
-
1
render json: {errors: [I18n.t("sessions.messages.passkey_not_found")]},
-
status: :unprocessable_content
-
end
-
end
-
-
4
private
-
-
4
def find_credential(webauthn_credential)
-
1
encoded_id = Base64.strict_encode64(webauthn_credential.raw_id)
-
1
Credential.find_by(external_id: encoded_id)
-
end
-
-
4
def verify_and_sign_in_with_passkey(credential, webauthn_credential)
-
challenge = session[:passkey_authentication]["challenge"]
-
webauthn_credential.verify(
-
challenge,
-
public_key: credential.public_key,
-
sign_count: credential.sign_count,
-
user_verification: true
-
)
-
-
credential.update!(sign_count: webauthn_credential.sign_count)
-
user = User.find(credential.user_id)
-
-
# Create session for passkey login
-
establish_user_session(user)
-
-
render json: {status: "ok"}, status: :ok
-
rescue WebAuthn::Error => e
-
error_msg = I18n.t("sessions.messages.passkey_login_failed")
-
render json: "#{error_msg}: #{e.message}",
-
status: :unprocessable_content
-
ensure
-
session.delete(:passkey_authentication)
-
end
-
-
4
def handle_successful_login(user)
-
594
establish_user_session(user)
-
594
flash[:notice] = I18n.t("session.login.success")
-
594
redirect_to inspections_path
-
end
-
end
-
4
class SlideAssessmentsController < ApplicationController
-
4
include AssessmentController
-
4
include SafetyStandardsTurboStreams
-
end
-
4
class StructureAssessmentsController < ApplicationController
-
4
include AssessmentController
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class UnitsController < ApplicationController
-
4
extend T::Sig
-
-
4
include TurboStreamResponders
-
4
include PublicViewable
-
4
include UserActivityCheck
-
-
4
skip_before_action :require_login, only: %i[show]
-
4
before_action :check_assessments_enabled
-
4
before_action :set_unit, only: %i[destroy edit log show update]
-
4
before_action :check_unit_owner, only: %i[destroy edit update]
-
4
before_action :check_log_access, only: %i[log]
-
4
before_action :require_user_active, only: %i[create new edit update]
-
4
before_action :no_index
-
-
4
def index
-
62
@units = filtered_units_query
-
62
@title = build_index_title
-
-
62
respond_to do |format|
-
62
format.html
-
62
format.csv do
-
2
log_unit_event("exported", nil, "Exported #{@units.count} units to CSV")
-
2
csv_data = UnitCsvExportService.new(@units).generate
-
2
send_data csv_data, filename: "units-#{Time.zone.today}.csv"
-
end
-
end
-
end
-
-
4
def show
-
# Handle federation HEAD requests
-
106
then: 1
else: 105
return head :ok if request.head?
-
-
105
@inspections = @unit.inspections
-
.includes(inspector_company: {logo_attachment: :blob})
-
.order(inspection_date: :desc)
-
-
105
respond_to do |format|
-
174
format.html { render_show_html }
-
121
format.pdf { send_unit_pdf }
-
109
format.png { send_unit_qr_code }
-
105
format.json do
-
16
render json: UnitBlueprint.render_with_inspections(@unit)
-
end
-
end
-
end
-
-
4
def new = @unit = Unit.new
-
-
4
def create
-
12
@unit = current_user.units.build(unit_params)
-
-
12
then: 0
else: 12
if @image_processing_error
-
flash.now[:alert] = @image_processing_error.message
-
handle_create_failure(@unit)
-
return
-
end
-
-
12
then: 8
if @unit.save
-
8
log_unit_event("created", @unit)
-
8
handle_create_success(@unit)
-
else: 4
else
-
4
handle_create_failure(@unit)
-
end
-
end
-
-
4
def edit = nil
-
-
4
def update
-
5
previous_attributes = @unit.attributes.dup
-
-
5
params_to_update = unit_params
-
-
5
then: 0
else: 5
if @image_processing_error
-
flash.now[:alert] = @image_processing_error.message
-
handle_update_failure(@unit)
-
return
-
end
-
-
5
if @unit.update(params_to_update)
-
then: 4
# Calculate what changed
-
4
changed_data = calculate_changes(
-
previous_attributes,
-
@unit.attributes,
-
unit_params.keys
-
)
-
-
4
log_unit_event("updated", @unit, nil, changed_data)
-
-
4
additional_streams = []
-
4
else: 4
if params[:unit][:photo].present?
-
then: 0
# Render just the file field without a new form wrapper
-
additional_streams << turbo_stream.replace(
-
"unit_photo_preview",
-
partial: "chobble_forms/file_field_turbo_response",
-
locals: {
-
model: @unit,
-
field: :photo,
-
turbo_frame_id: "unit_photo_preview",
-
i18n_base: "forms.units",
-
accept: "image/*"
-
}
-
)
-
end
-
-
4
handle_update_success(@unit, nil, nil, additional_streams: additional_streams)
-
else: 1
else
-
1
handle_update_failure(@unit)
-
end
-
end
-
-
4
def destroy
-
# Capture unit details before deletion for the audit log
-
unit_details = {
-
5
name: @unit.name,
-
serial: @unit.serial,
-
operator: @unit.operator,
-
manufacturer: @unit.manufacturer
-
}
-
-
5
if @unit.destroy
-
then: 4
# Log the deletion with the unit details in metadata
-
4
Event.log(
-
user: current_user,
-
action: "deleted",
-
resource: @unit,
-
details: nil,
-
metadata: unit_details
-
)
-
4
flash[:notice] = I18n.t("units.messages.deleted")
-
4
redirect_to units_path
-
else: 1
else
-
error_message =
-
1
@unit.errors.full_messages.first ||
-
I18n.t("units.messages.delete_failed")
-
1
flash[:alert] = error_message
-
1
redirect_to @unit
-
end
-
end
-
-
4
def log
-
2
@events = Event.for_resource(@unit).recent.includes(:user)
-
2
@title = I18n.t("units.titles.log", unit: @unit.name)
-
end
-
-
4
def new_from_inspection
-
4
@inspection = current_user.inspections.find_by(id: params[:id])
-
-
4
else: 3
then: 1
unless @inspection
-
1
flash[:alert] = I18n.t("units.errors.inspection_not_found")
-
1
redirect_to root_path and return
-
end
-
-
3
then: 1
else: 2
if @inspection.unit
-
1
flash[:alert] = I18n.t("units.errors.inspection_has_unit")
-
1
redirect_to inspection_path(@inspection) and return
-
end
-
-
2
@unit = Unit.new(user: current_user)
-
end
-
-
4
def create_from_inspection
-
5
service = UnitCreationFromInspectionService.new(
-
user: current_user,
-
inspection_id: params[:id],
-
unit_params: unit_params
-
)
-
-
5
then: 2
if service.create
-
2
log_unit_event("created", service.unit)
-
2
flash[:notice] = I18n.t("units.messages.created_from_inspection")
-
2
else: 3
redirect_to edit_inspection_path(service.inspection)
-
3
then: 2
elsif service.error_message
-
2
flash[:alert] = service.error_message
-
2
then: 1
redirect_path = service.inspection ?
-
1
else: 1
inspection_path(service.inspection) :
-
1
root_path
-
2
redirect_to redirect_path
-
else: 1
else
-
1
@unit = service.unit
-
1
@inspection = service.inspection
-
1
render :new_from_inspection, status: :unprocessable_content
-
end
-
end
-
-
4
private
-
-
4
def log_unit_event(action, unit, details = nil, changed_data = nil)
-
16
else: 16
then: 0
return unless current_user
-
-
16
then: 14
if unit
-
14
Event.log(
-
user: current_user,
-
action: action,
-
resource: unit,
-
details: details,
-
changed_data: changed_data
-
)
-
else
-
else: 2
# For events without a specific unit (like CSV export)
-
2
Event.log_system_event(
-
user: current_user,
-
action: action,
-
details: details,
-
metadata: {resource_type: "Unit"}
-
)
-
end
-
rescue => e
-
Rails.logger.error I18n.t("units.errors.log_failed", message: e.message)
-
end
-
-
4
def calculate_changes(previous_attributes, current_attributes, changed_keys)
-
4
changes = {}
-
-
4
changed_keys.map(&:to_s).each do |key|
-
22
previous_value = previous_attributes[key]
-
22
current_value = current_attributes[key]
-
-
22
then: 6
else: 16
if previous_value != current_value
-
6
changes[key] = {
-
"from" => previous_value,
-
"to" => current_value
-
}
-
end
-
end
-
-
4
changes.presence
-
end
-
-
4
def unit_params
-
26
permitted_params = params.require(:unit).permit(*%i[
-
description
-
manufacture_date
-
manufacturer
-
name
-
operator
-
photo
-
serial
-
unit_type
-
])
-
-
26
process_image_params(permitted_params, :photo)
-
end
-
-
4
def no_index = response.set_header("X-Robots-Tag", "noindex,nofollow")
-
-
4
def set_unit
-
151
@unit = Unit.includes(photo_attachment: :blob)
-
.find_by(id: params[:id].upcase)
-
-
151
else: 139
unless @unit
-
then: 12
# Always return 404 for non-existent resources regardless of login status
-
12
head :not_found
-
end
-
end
-
-
4
def check_unit_owner
-
31
else: 28
then: 3
head :not_found unless owns_resource?
-
end
-
-
4
def check_log_access
-
# Only unit owners can view logs
-
2
else: 2
then: 0
head :not_found unless owns_resource?
-
end
-
-
4
def check_assessments_enabled
-
252
else: 252
then: 0
head :not_found unless ENV["HAS_ASSESSMENTS"] == "true"
-
end
-
-
4
def send_unit_pdf
-
# Unit already has photo loaded from set_unit
-
16
result = PdfCacheService.fetch_or_generate_unit_pdf(
-
@unit,
-
debug_enabled: admin_debug_enabled?,
-
debug_queries: debug_sql_queries
-
)
-
-
16
handle_pdf_response(result, "#{@unit.serial}.pdf")
-
end
-
-
4
def send_unit_qr_code
-
4
qr_code_png = QrCodeService.generate_qr_code(@unit)
-
-
4
send_data qr_code_png,
-
filename: "#{@unit.serial}_QR.png",
-
type: "image/png",
-
disposition: "inline"
-
end
-
-
# PublicViewable implementation
-
4
def check_resource_owner
-
check_unit_owner
-
end
-
-
4
def owns_resource?
-
95
@unit && current_user && @unit.user_id == current_user.id
-
end
-
-
4
def pdf_filename
-
10
"#{@unit.serial}.pdf"
-
end
-
-
4
def resource_pdf_url
-
10
unit_path(@unit, format: :pdf)
-
end
-
-
4
def filtered_units_query
-
62
units = current_user.units.includes(photo_attachment: :blob)
-
62
units = units.search(params[:query])
-
62
then: 3
else: 59
units = units.overdue if params[:status] == "overdue"
-
62
units = units.by_manufacturer(params[:manufacturer])
-
62
units = units.by_operator(params[:operator])
-
62
units.order(created_at: :desc)
-
end
-
-
4
def build_index_title
-
62
title_parts = [I18n.t("units.titles.index")]
-
62
then: 3
else: 59
if params[:status] == "overdue"
-
3
title_parts << I18n.t("units.status.overdue")
-
end
-
62
then: 7
else: 55
title_parts << params[:manufacturer] if params[:manufacturer].present?
-
62
then: 5
else: 57
title_parts << params[:operator] if params[:operator].present?
-
62
title_parts.join(" - ")
-
end
-
-
4
def handle_inactive_user_redirect
-
5
redirect_to units_path
-
end
-
end
-
# typed: false
-
-
4
class UserHeightAssessmentsController < ApplicationController
-
4
include AssessmentController
-
4
include SafetyStandardsTurboStreams
-
-
4
private
-
-
4
def success_turbo_streams(additional_info: nil)
-
# Call parent which includes SafetyStandardsTurboStreams
-
3
streams = super
-
-
# Add our field update streams if any fields were defaulted
-
3
then: 3
else: 0
else: 2
then: 1
return streams unless @fields_defaulted_to_zero&.any?
-
-
2
streams + field_update_streams
-
end
-
-
4
def field_update_streams
-
2
form_config = assessment_class.form_fields
-
-
2
@fields_defaulted_to_zero.map do |field|
-
8
field_config = find_field_config(form_config, field)
-
8
else: 0
then: 8
next unless field_config
-
-
build_field_turbo_stream(field, field_config)
-
end.compact
-
end
-
-
4
def build_field_turbo_stream(field, field_config)
-
turbo_stream.replace(
-
field,
-
partial: "chobble_forms/field_turbo_response",
-
locals: {
-
model: @assessment,
-
field:,
-
partial: field_config[:partial],
-
i18n_base: "forms.user_height",
-
attributes: field_config[:attributes] || {}
-
}
-
)
-
end
-
-
4
def find_field_config(form_config, field_name)
-
# The YAML loads field names as strings, not symbols
-
8
field_str = field_name.to_s
-
8
form_config.each do |fieldset|
-
32
fieldset[:fields].each do |field_config|
-
72
then: 0
else: 72
return field_config if field_config[:field] == field_str
-
end
-
end
-
8
nil
-
end
-
-
4
def preprocess_values
-
15
@fields_defaulted_to_zero = []
-
-
# The param key matches the model's param_key
-
15
param_key = assessment_class.model_name.param_key
-
15
else: 15
then: 0
return unless params[param_key]
-
-
15
apply_user_height_defaults(param_key)
-
end
-
-
4
def apply_user_height_defaults(param_key)
-
user_height_fields = %w[
-
15
users_at_1000mm
-
users_at_1200mm
-
users_at_1500mm
-
users_at_1800mm
-
]
-
-
15
user_height_fields.each do |field|
-
60
then: 32
else: 28
if params[param_key][field].blank?
-
32
params[param_key][field] = "0"
-
32
@fields_defaulted_to_zero << field
-
end
-
end
-
end
-
-
4
def build_additional_info
-
14
then: 14
else: 0
else: 8
then: 6
return nil unless @fields_defaulted_to_zero&.any?
-
-
8
fields = @fields_defaulted_to_zero
-
36
field_names = fields.map { |f| I18n.t("forms.user_height.fields.#{f}") }
-
8
I18n.t(
-
"inspections.messages.user_height_defaults_applied",
-
fields: field_names.join(", ")
-
)
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
class UsersController < ApplicationController
-
4
include SessionManagement
-
4
include TurboStreamResponders
-
-
4
NON_ADMIN_PATHS = %i[
-
change_settings
-
change_password
-
update_settings
-
update_password
-
logout_everywhere_else
-
stop_impersonating
-
].freeze
-
-
4
LOGGED_OUT_PATHS = %i[
-
create
-
new
-
].freeze
-
-
4
skip_before_action :require_login, only: LOGGED_OUT_PATHS
-
4
skip_before_action :update_last_active_at, only: [:update_settings]
-
4
before_action :set_user, except: %i[index new create]
-
4
before_action :require_admin, except: NON_ADMIN_PATHS + LOGGED_OUT_PATHS
-
4
before_action :require_correct_user, only: NON_ADMIN_PATHS
-
-
4
def index
-
7
@users = apply_sort(User.all)
-
7
@inspection_counts = Inspection
-
.where(user_id: @users.pluck(:id))
-
.group(:user_id)
-
.count
-
end
-
-
4
def new
-
7
@user = User.new
-
end
-
-
4
def create
-
11
@user = User.new(user_params)
-
11
then: 8
if @user.save
-
8
then: 1
else: 7
send_new_user_notifications(@user) if Rails.env.production?
-
-
8
establish_user_session(@user)
-
8
flash[:notice] = I18n.t("users.messages.account_created")
-
8
redirect_to root_path
-
else: 3
else
-
3
render :new, status: :unprocessable_content
-
end
-
end
-
-
4
def edit
-
end
-
-
4
def update
-
# Convert empty string to nil for inspection_company_id
-
10
then: 4
else: 6
params[:user][:inspection_company_id] = nil if params[:user][:inspection_company_id] == ""
-
-
10
then: 9
if @user.update(user_params)
-
9
handle_update_success(@user, "users.messages.user_updated", users_path)
-
else: 1
else
-
1
handle_update_failure(@user)
-
end
-
end
-
-
4
def destroy
-
1
@user.destroy
-
1
flash[:notice] = I18n.t("users.messages.user_deleted")
-
1
redirect_to users_path
-
end
-
-
4
def change_password
-
end
-
-
4
def update_password
-
3
then: 2
if @user.authenticate(params[:user][:current_password])
-
2
then: 1
if @user.update(password_params)
-
1
flash[:notice] = I18n.t("users.messages.password_updated")
-
1
redirect_to root_path
-
else: 1
else
-
1
render :change_password, status: :unprocessable_content
-
end
-
else: 1
else
-
1
@user.errors.add(:current_password, I18n.t("users.errors.wrong_password"))
-
1
render :change_password, status: :unprocessable_content
-
end
-
end
-
-
4
extend T::Sig
-
-
5
sig { void }
-
4
def impersonate
-
6
then: 6
else: 0
session[:original_admin_id] = current_user.id if current_user.admin?
-
6
switch_to_user(@user)
-
6
flash[:notice] = I18n.t("users.messages.impersonating", email: @user.email)
-
6
redirect_to root_path
-
end
-
-
5
sig { void }
-
4
def stop_impersonating
-
1
else: 1
then: 0
return redirect_to root_path unless session[:original_admin_id]
-
-
1
admin_user = User.find(session[:original_admin_id])
-
1
switch_to_user(admin_user)
-
1
session.delete(:original_admin_id)
-
1
flash[:notice] = I18n.t("users.messages.stopped_impersonating")
-
1
redirect_to root_path
-
end
-
-
4
def change_settings
-
end
-
-
4
def update_settings
-
14
params_to_update = settings_params
-
-
14
then: 3
else: 11
if @image_processing_error
-
3
flash[:alert] = @image_processing_error.message
-
3
redirect_to change_settings_user_path(@user)
-
3
return
-
end
-
-
11
then: 10
if @user.update(params_to_update)
-
10
additional_streams = []
-
-
10
then: 3
else: 7
if params[:user][:logo].present?
-
3
additional_streams << turbo_stream.replace(
-
"user_logo_field",
-
partial: "chobble_forms/file_field_turbo_response",
-
locals: {
-
model: @user,
-
field: :logo,
-
turbo_frame_id: "user_logo_field",
-
i18n_base: "forms.user_settings",
-
accept: "image/*"
-
}
-
)
-
end
-
-
10
then: 1
else: 9
if params[:user][:signature].present?
-
1
additional_streams << turbo_stream.replace(
-
"user_signature_field",
-
partial: "chobble_forms/file_field_turbo_response",
-
locals: {
-
model: @user,
-
field: :signature,
-
turbo_frame_id: "user_signature_field",
-
i18n_base: "forms.user_settings",
-
accept: "image/*"
-
}
-
)
-
end
-
-
10
handle_update_success(
-
@user,
-
"users.messages.settings_updated",
-
change_settings_user_path(@user),
-
additional_streams: additional_streams
-
)
-
else: 1
else
-
1
handle_update_failure(@user, :change_settings)
-
end
-
end
-
-
4
def verify_rpii
-
4
result = @user.verify_rpii_inspector_number
-
-
4
respond_to do |format|
-
4
format.html do
-
4
then: 2
if result[:valid]
-
2
flash[:notice] = I18n.t("users.messages.rpii_verified")
-
else: 2
else
-
2
flash[:alert] = get_rpii_error_message(result)
-
end
-
4
redirect_to edit_user_path(@user)
-
end
-
-
4
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace("rpii_verification_result",
-
partial: "users/rpii_verification_result",
-
locals: {result: result, user: @user})
-
end
-
end
-
end
-
-
4
def add_seeds
-
4
then: 1
if @user.has_seed_data?
-
1
flash[:alert] = I18n.t("users.messages.seeds_failed")
-
else: 3
else
-
3
SeedDataService.add_seeds_for_user(@user)
-
3
flash[:notice] = I18n.t("users.messages.seeds_added")
-
end
-
4
redirect_to edit_user_path(@user)
-
end
-
-
4
def delete_seeds
-
3
SeedDataService.delete_seeds_for_user(@user)
-
3
flash[:notice] = I18n.t("users.messages.seeds_deleted")
-
3
redirect_to edit_user_path(@user)
-
end
-
-
4
def activate
-
@user.update(active_until: 1000.years.from_now)
-
flash[:notice] = I18n.t("users.messages.user_activated")
-
redirect_to edit_user_path(@user)
-
end
-
-
4
def deactivate
-
@user.update(active_until: Time.current)
-
flash[:notice] = I18n.t("users.messages.user_deactivated")
-
redirect_to edit_user_path(@user)
-
end
-
-
4
def logout_everywhere_else
-
# Delete all sessions except the current one
-
2
current_token = session[:session_token]
-
2
@user.user_sessions.where.not(session_token: current_token).destroy_all
-
2
flash[:notice] = I18n.t("users.messages.logged_out_everywhere")
-
2
redirect_to change_settings_user_path(@user)
-
end
-
-
4
private
-
-
7
sig { params(scope: T.untyped).returns(T.untyped) }
-
4
def apply_sort(scope)
-
7
case params[:sort]
-
when: 0
when "oldest"
-
scope.order(created_at: :asc)
-
when: 0
when "most_active"
-
scope.order(last_active_at: :desc)
-
when: 0
when "most_inspections"
-
scope.left_joins(:inspections)
-
.group("users.id")
-
.order("COUNT(inspections.id) DESC")
-
else: 7
else
-
7
scope.order(created_at: :desc) # newest first is default
-
end
-
end
-
-
5
sig { params(user: User).void }
-
4
def switch_to_user(user)
-
7
then: 7
else: 0
terminate_current_session if session[:session_token]
-
7
establish_user_session(user)
-
end
-
-
4
def get_rpii_error_message(result)
-
2
case result[:error]
-
when: 0
when :blank_number
-
I18n.t("users.messages.rpii_blank_number")
-
when: 0
when :blank_name
-
I18n.t("users.messages.rpii_blank_name")
-
when: 1
when :name_mismatch
-
1
inspector = result[:inspector]
-
1
I18n.t("users.messages.rpii_name_mismatch",
-
user_name: @user.name,
-
inspector_name: inspector[:name])
-
when: 1
when :not_found
-
1
I18n.t("users.messages.rpii_not_found")
-
else: 0
else
-
I18n.t("users.messages.rpii_verification_failed")
-
end
-
end
-
-
4
def set_user
-
122
@user = User.find(params[:id])
-
end
-
-
4
def user_params
-
21
then: 10
else: 11
then: 10
if current_user&.admin?
-
10
admin_permitted_params = %i[
-
active_until email inspection_company_id name password
-
password_confirmation rpii_inspector_number
-
]
-
10
else: 11
params.require(:user).permit(admin_permitted_params)
-
11
then: 11
elsif action_name == "create"
-
11
params.require(:user).permit(:email, :name, :rpii_inspector_number, :password, :password_confirmation)
-
else: 0
else
-
params.require(:user).permit(:email, :password, :password_confirmation)
-
end
-
end
-
-
4
def require_correct_user
-
45
then: 42
else: 3
return if current_user == @user
-
-
3
then: 1
else: 2
action = action_name.include?("password") ? "password" : "settings"
-
3
flash[:alert] = I18n.t("users.messages.own_action_only", action: action)
-
3
redirect_to root_path
-
end
-
-
4
def password_params
-
2
params.require(:user).permit(:password, :password_confirmation)
-
end
-
-
4
def settings_params
-
14
settings_fields = %i[
-
address country
-
logo phone postal_code signature theme
-
]
-
14
permitted_params = params.require(:user).permit(settings_fields)
-
-
14
process_image_params(permitted_params, :logo, :signature)
-
end
-
-
4
def send_new_user_notifications(user)
-
1
developer_notification = I18n.t("users.messages.new_user_notification",
-
email: user.email)
-
-
1
NtfyService.notify(developer_notification, channel: :developer)
-
-
1
anonymized_email = helpers.anonymise_email(user.email)
-
-
1
admin_notification = I18n.t("users.messages.new_user_notification",
-
email: anonymized_email)
-
-
1
NtfyService.notify(admin_notification, channel: :admin)
-
end
-
end
-
4
module ApplicationErrors
-
4
class NotAnImageError < StandardError
-
4
def initialize(message = nil)
-
3
super(message || I18n.t("errors.messages.invalid_image_format"))
-
end
-
end
-
-
4
class ImageProcessingError < StandardError
-
4
def initialize(message = nil)
-
super(message || I18n.t("errors.messages.image_processing_failed"))
-
end
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module ApplicationHelper
-
4
extend T::Sig
-
4
include ActionView::Helpers::NumberHelper
-
-
8
sig { params(datetime: T.nilable(T.any(Date, Time, DateTime, ActiveSupport::TimeWithZone))).returns(T.nilable(String)) }
-
4
then: 299
else: 1
def render_time(datetime) = datetime&.strftime("%b %d, %Y")
-
-
5
sig { params(datetime: T.nilable(T.any(Date, Time, DateTime, ActiveSupport::TimeWithZone))).returns(T.nilable(Date)) }
-
4
then: 2
else: 1
def date_for_form(datetime) = datetime&.to_date
-
-
6
sig { params(html_options: T::Hash[Symbol, String], block: T.proc.void).returns(String) }
-
4
def scrollable_table(html_options = {}, &block)
-
8
content_tag(:div, class: "table-container") do
-
8
content_tag(:table, html_options, &block)
-
end
-
end
-
-
8
sig { returns(String) }
-
4
def effective_theme
-
1562
then: 1065
else: 497
ENV["THEME"] || current_user&.theme || "light"
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def theme_selector_disabled? = ENV["THEME"].present?
-
-
8
sig { returns(String) }
-
4
def logo_path
-
1562
ENV["LOGO_PATH"] || "logo.svg"
-
end
-
-
8
sig { returns(String) }
-
4
def logo_alt_text
-
1562
ENV["LOGO_ALT"] || "play-test logo"
-
end
-
-
8
sig { returns(T.nilable(String)) }
-
4
def left_logo_path
-
1562
ENV["LEFT_LOGO_PATH"]
-
end
-
-
4
sig { returns(String) }
-
4
def left_logo_alt
-
ENV["LEFT_LOGO_ALT"] || "Logo"
-
end
-
-
8
sig { returns(T.nilable(String)) }
-
4
def right_logo_path
-
1562
ENV["RIGHT_LOGO_PATH"]
-
end
-
-
4
sig { returns(String) }
-
4
def right_logo_alt
-
ENV["RIGHT_LOGO_ALT"] || "Logo"
-
end
-
-
8
sig { params(slug: String).returns(T.any(String, ActiveSupport::SafeBuffer)) }
-
4
def page_snippet(slug)
-
1562
snippet = Page.snippets.find_by(slug: slug)
-
1562
else: 122
then: 1440
return "" unless snippet
-
122
raw snippet.content
-
end
-
-
8
sig { params(name: String, path: String, options: T::Hash[Symbol, T.any(String, Symbol)]).returns(String) }
-
4
def nav_link_to(name, path, options = {})
-
6590
then: 1011
css_class = if current_page?(path) || controller_matches?(path)
-
1011
"active"
-
else: 5579
else
-
5579
""
-
end
-
6590
link_to name, path, options.merge(class: css_class)
-
end
-
-
8
sig { params(value: T.untyped).returns(T.untyped) }
-
4
def format_numeric_value(value)
-
208
else: 202
if value.is_a?(String) &&
-
value.match?(/\A-?\d*\.?\d+\z/) &&
-
6
then: 6
(float_value = Float(value, exception: false))
-
6
value = float_value
-
end
-
-
208
else: 54
then: 154
return value unless value.is_a?(Numeric)
-
-
54
number_with_precision(
-
value,
-
precision: 4,
-
strip_insignificant_zeros: true
-
)
-
end
-
-
6
sig { params(email: String).returns(String) }
-
4
def anonymise_email(email)
-
9
else: 8
then: 1
return email unless email.include?("@")
-
-
8
local_part, domain = email.split("@", 2)
-
8
domain_parts = domain.split(".", 2)
-
-
8
anonymised_local = anonymise_string(local_part)
-
8
anonymised_domain_name = anonymise_string(domain_parts[0])
-
-
8
then: 7
if domain_parts.length > 1
-
7
"#{anonymised_local}@#{anonymised_domain_name}.#{domain_parts[1]}"
-
else: 1
else
-
1
"#{anonymised_local}@#{anonymised_domain_name}"
-
end
-
end
-
-
4
private
-
-
6
sig { params(str: String).returns(String) }
-
4
def anonymise_string(str)
-
16
then: 2
else: 14
return str if str.length <= 2
-
-
14
first_char = str[0]
-
14
last_char = str[-1]
-
14
middle_length = str.length - 2
-
-
14
"#{first_char}#{"*" * middle_length}#{last_char}"
-
end
-
-
8
sig { params(path: String).returns(T::Boolean) }
-
4
def controller_matches?(path)
-
6082
route = Rails.application.routes.recognize_path(path)
-
6082
path_controller = route[:controller]
-
6082
controller_name == path_controller
-
rescue ActionController::RoutingError
-
false
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module InspectionsHelper
-
4
extend T::Sig
-
-
5
sig { params(user: User).returns(String) }
-
4
def format_inspection_count(user)
-
1
count = user.inspections.count
-
1
t("inspections.count", count: count)
-
end
-
-
8
sig { params(inspection: Inspection).returns(String) }
-
4
def inspection_result_badge(inspection)
-
215
else: 0
case inspection.passed
-
when: 148
when true
-
148
content_tag(:span, t("inspections.status.pass"), class: "pass-badge")
-
when: 19
when false
-
19
content_tag(:span, t("inspections.status.fail"), class: "fail-badge")
-
when: 48
when nil
-
48
content_tag(:span, t("inspections.status.pending"), class: "pending-badge")
-
end
-
end
-
-
4
sig {
-
4
params(inspection: Inspection).returns(
-
T::Array[T::Hash[Symbol, T.any(String, Symbol, T::Boolean)]]
-
)
-
}
-
4
def inspection_actions(inspection)
-
88
actions = T.let([], T::Array[T::Hash[Symbol, T.any(String, Symbol, T::Boolean)]])
-
-
88
if inspection.complete?
-
then: 31
# Complete inspections: Switch to In Progress / Log
-
31
actions << {
-
label: t("inspections.buttons.switch_to_in_progress"),
-
url: mark_draft_inspection_path(inspection),
-
method: :patch,
-
confirm: t("inspections.messages.mark_in_progress_confirm"),
-
button: true
-
}
-
31
actions << {
-
label: t("inspections.buttons.log"),
-
url: log_inspection_path(inspection)
-
}
-
else
-
else: 57
# Incomplete inspections: Update Inspection / Log / Delete Inspection
-
57
actions << {
-
label: t("inspections.buttons.update"),
-
url: edit_inspection_path(inspection)
-
}
-
57
actions << {
-
label: t("inspections.buttons.log"),
-
url: log_inspection_path(inspection)
-
}
-
57
actions << {
-
label: t("inspections.buttons.delete"),
-
url: inspection_path(inspection),
-
method: :delete,
-
confirm: t("inspections.messages.delete_confirm"),
-
danger: true
-
}
-
end
-
-
88
actions
-
end
-
-
# Tabbed inspection editing helpers
-
8
sig { params(inspection: Inspection).returns(T::Array[String]) }
-
4
def inspection_tabs(inspection)
-
258
inspection.applicable_tabs
-
end
-
-
8
sig { returns(String) }
-
4
def current_tab
-
1711
params[:tab].presence || "inspection"
-
end
-
-
8
sig { params(inspection: Inspection, tab: String).returns(T::Boolean) }
-
4
def assessment_complete?(inspection, tab)
-
1604
case tab
-
when "inspection"
-
when: 237
# For the main inspection tab, check if required fields are filled (excluding passed)
-
237
inspection.inspection_tab_incomplete_fields.empty?
-
when "results"
-
when: 182
# For results tab, check if passed field is filled (risk_assessment is optional)
-
182
inspection.passed.present?
-
else
-
else: 1185
# For assessment tabs, check the corresponding assessment
-
1185
assessment_method = "#{tab}_assessment"
-
1185
assessment = inspection.public_send(assessment_method)
-
1185
then: 1185
else: 0
assessment&.complete? || false
-
end
-
end
-
-
8
sig { params(inspection: Inspection, tab: String).returns(String) }
-
4
def tab_name_with_check(inspection, tab)
-
1474
name = t("forms.#{tab}.header")
-
1474
then: 548
else: 926
assessment_complete?(inspection, tab) ? "#{name} ✓" : name
-
end
-
-
8
sig { params(inspection: Inspection, current_tab: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
-
4
def next_tab_navigation_info(inspection, current_tab)
-
# Don't show continue message on results tab
-
59
then: 1
else: 58
return nil if current_tab == "results"
-
-
58
all_tabs = inspection.applicable_tabs
-
58
current_index = all_tabs.index(current_tab)
-
58
else: 58
then: 0
return nil unless current_index
-
-
58
tabs_after = all_tabs[(current_index + 1)..]
-
-
# Check if current tab is incomplete
-
58
current_tab_incomplete = !assessment_complete?(inspection, current_tab)
-
-
# Find first incomplete tab after current (excluding results for now)
-
58
next_incomplete = tabs_after.find { |tab|
-
76
tab != "results" && !assessment_complete?(inspection, tab)
-
}
-
-
# If current tab is incomplete and there's a next tab available
-
58
then: 53
else: 5
if current_tab_incomplete && tabs_after.any?
-
53
incomplete_count = incomplete_fields_count(inspection, current_tab)
-
-
# If there's an incomplete tab after, user should skip current incomplete
-
53
then: 52
else: 1
if next_incomplete
-
52
return {tab: next_incomplete, skip_incomplete: true, incomplete_count: incomplete_count}
-
end
-
-
# If results tab is incomplete, user should skip to results
-
1
then: 0
else: 1
if tabs_after.include?("results") && inspection.passed.nil?
-
return {tab: "results", skip_incomplete: true, incomplete_count: incomplete_count}
-
end
-
-
# Don't suggest next tab if it's complete and there are no incomplete tabs
-
1
return nil
-
end
-
-
# Current tab is complete, just suggest next incomplete tab
-
5
then: 2
else: 3
if next_incomplete
-
2
return {tab: next_incomplete, skip_incomplete: false}
-
end
-
-
# Check if results tab is incomplete
-
3
then: 1
else: 2
if tabs_after.include?("results") && inspection.passed.nil?
-
1
return {tab: "results", skip_incomplete: false}
-
end
-
-
2
nil
-
end
-
-
7
sig { params(inspection: Inspection, tab: String).returns(Integer) }
-
4
def incomplete_fields_count(inspection, tab)
-
59
@incomplete_fields_cache = T.let(@incomplete_fields_cache, T.nilable(T::Hash[String, Integer])) || {}
-
59
cache_key = "#{inspection.id}_#{tab}"
-
-
59
@incomplete_fields_cache[cache_key] ||= case tab
-
when: 30
when "inspection"
-
30
inspection.inspection_tab_incomplete_fields.length
-
when: 2
when "results"
-
2
then: 1
else: 1
inspection.passed.nil? ? 1 : 0
-
else: 1
else
-
1
assessment = inspection.public_send("#{tab}_assessment")
-
1
then: 1
if assessment
-
1
grouped = assessment.incomplete_fields_grouped
-
9
grouped.values.sum { |group| group[:fields].length }
-
else: 0
else
-
0
-
end
-
end
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module PagesHelper
-
4
extend T::Sig
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module SessionsHelper
-
4
extend T::Sig
-
-
8
sig { void }
-
4
def remember_user
-
613
then: 612
else: 1
if session[:session_token]
-
612
cookies.permanent.signed[:session_token] = session[:session_token]
-
end
-
end
-
-
8
sig { void }
-
4
def forget_user
-
27
cookies.delete(:session_token)
-
end
-
-
8
sig { returns(T.nilable(User)) }
-
4
def current_user
-
19065
@current_user ||= fetch_current_user
-
end
-
-
4
private
-
-
8
sig { returns(T.nilable(User)) }
-
4
def fetch_current_user
-
5402
then: 1468
if session[:session_token]
-
1468
else: 3934
user_from_session_token
-
3934
then: 6
else: 3928
elsif cookies.signed[:session_token]
-
6
user_from_cookie_token
-
end
-
end
-
-
8
sig { returns(T.nilable(User)) }
-
4
def user_from_session_token
-
1468
user_session = UserSession.find_by(session_token: session[:session_token])
-
1468
then: 1462
if user_session
-
1462
user_session.user
-
else
-
else: 6
# Session token is invalid, clear session
-
6
session.delete(:session_token)
-
6
nil
-
end
-
end
-
-
6
sig { returns(T.nilable(User)) }
-
4
def user_from_cookie_token
-
6
token = cookies.signed[:session_token]
-
6
else: 6
then: 0
return unless token
-
-
6
user_session = UserSession.find_by(session_token: token)
-
6
if user_session
-
then: 1
# Restore session from cookie
-
1
session[:session_token] = token
-
1
user_session.user
-
else
-
else: 5
# Invalid cookie token, clear it
-
5
cookies.delete(:session_token)
-
5
nil
-
end
-
end
-
-
4
public
-
-
8
sig { returns(T::Boolean) }
-
4
def logged_in?
-
2477
!current_user.nil?
-
end
-
-
8
sig { void }
-
4
def log_out
-
27
session.delete(:session_token)
-
27
session.delete(:original_admin_id) # Clear impersonation tracking
-
27
forget_user
-
27
@current_user = nil
-
end
-
-
4
sig {
-
4
params(
-
email: T.nilable(String),
-
password: T.nilable(String)
-
).returns(T.nilable(T.any(User, T::Boolean)))
-
}
-
4
def authenticate_user(email, password)
-
608
else: 604
then: 4
return nil unless email.present? && password.present?
-
604
then: 601
else: 3
User.find_by(email: email.downcase)&.authenticate(password)
-
end
-
-
8
sig { params(user: User).void }
-
4
def create_user_session(user)
-
610
remember_user
-
end
-
-
8
sig { returns(T.nilable(UserSession)) }
-
4
def current_session
-
1448
else: 1447
then: 1
return unless session[:session_token]
-
1447
@current_session ||= UserSession.find_by(
-
session_token: session[:session_token]
-
)
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module UnitsHelper
-
4
extend T::Sig
-
-
7
sig { params(user: User).returns(T::Array[String]) }
-
4
def manufacturer_options(user)
-
51
user.units.distinct.pluck(:manufacturer).compact.compact_blank.sort
-
end
-
-
8
sig { params(user: User).returns(T::Array[String]) }
-
4
def operator_options(user)
-
156
user.units.distinct.pluck(:operator).compact.compact_blank.sort
-
end
-
-
6
sig { returns(String) }
-
4
def unit_search_placeholder
-
28
serial_label = ChobbleForms::FieldUtils.form_field_label(:units, :serial)
-
28
name_label = ChobbleForms::FieldUtils.form_field_label(:units, :name)
-
28
"#{serial_label} or #{name_label.downcase}"
-
end
-
-
4
sig {
-
4
params(unit: Unit).returns(
-
T::Array[
-
T::Hash[Symbol, T.any(String, Symbol, T::Boolean, T::Hash[Symbol, String])]
-
]
-
)
-
}
-
4
def unit_actions(unit)
-
92
actions = T.let([
-
{
-
label: I18n.t("units.buttons.view"),
-
url: unit_path(unit, anchor: "inspections")
-
},
-
{
-
label: I18n.t("ui.edit"),
-
url: edit_unit_path(unit)
-
},
-
{
-
label: I18n.t("units.buttons.pdf_report"),
-
url: unit_path(unit, format: :pdf)
-
}
-
], T::Array[
-
T::Hash[Symbol, T.any(String, Symbol, T::Boolean, T::Hash[Symbol, String])]
-
])
-
-
# Add activity log link for admins and unit owners
-
92
then: 77
else: 15
if current_user && (current_user.admin? || unit.user_id == current_user.id)
-
77
actions << {
-
label: I18n.t("units.links.view_log"),
-
url: log_unit_path(unit)
-
}
-
end
-
-
92
then: 79
else: 13
if unit.deletable?
-
79
actions << {
-
label: I18n.t("units.buttons.delete"),
-
url: unit,
-
method: :delete,
-
danger: true,
-
confirm: I18n.t("units.messages.delete_confirm")
-
}
-
end
-
-
92
actions << {
-
label: I18n.t("units.buttons.add_inspection"),
-
url: inspections_path,
-
method: :post,
-
params: {unit_id: unit.id},
-
confirm: I18n.t("units.messages.add_inspection_confirm")
-
}
-
-
92
actions
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module UsersHelper
-
4
extend T::Sig
-
-
5
sig { params(user: User).returns(String) }
-
4
def admin_status(user)
-
2
then: 1
else: 1
user.admin? ? "Yes" : "No"
-
end
-
-
5
sig { params(user: User).returns(String) }
-
4
def inspection_count(user)
-
3
count = user.inspections.count
-
3
then: 1
else: 2
"#{count} #{(count == 1) ? "inspection" : "inspections"}"
-
end
-
-
5
sig { params(time: T.nilable(T.any(Time, DateTime, ActiveSupport::TimeWithZone))).returns(String) }
-
4
def format_job_time(time)
-
2
else: 1
then: 1
return "Never" unless time
-
1
"#{time_ago_in_words(time)} ago"
-
end
-
end
-
# frozen_string_literal: true
-
-
4
class ApplicationJob < ActiveJob::Base
-
# Automatically retry jobs that encountered a deadlock
-
# retry_on ActiveRecord::Deadlocked
-
-
# Most jobs are safe to ignore if the underlying records are no longer available
-
# discard_on ActiveJob::DeserializationError
-
end
-
# frozen_string_literal: true
-
-
4
class S3BackupJob < ApplicationJob
-
4
queue_as :default
-
-
4
def perform
-
# Ensure Rails is fully loaded for background jobs
-
then: 0
else: 0
Rails.application.eager_load! if Rails.env.production?
-
-
result = S3BackupService.new.perform
-
-
Rails.logger.info "S3BackupJob completed successfully"
-
Rails.logger.info "Backup location: #{result[:location]}"
-
Rails.logger.info "Backup size: #{result[:size_mb]} MB"
-
then: 0
else: 0
Rails.logger.info "Deleted #{result[:deleted_count]} old backups" if result[:deleted_count].positive?
-
end
-
end
-
# frozen_string_literal: true
-
-
4
class SentryTestJob < ApplicationJob
-
4
queue_as :default
-
-
4
def perform(error_type = nil)
-
service = SentryTestService.new
-
-
if error_type
-
then: 0
# Test a specific error type
-
service.test_error_type(error_type.to_sym)
-
Rails.logger.info "SentryTestJob: Sent #{error_type} error to Sentry"
-
else
-
else: 0
# Run all tests
-
result = service.perform
-
-
Rails.logger.info "SentryTestJob completed:"
-
result[:results].each do |test_result|
-
then: 0
else: 0
status_emoji = (test_result[:status] == "success") ? "✅" : "❌"
-
Rails.logger.info " #{status_emoji} #{test_result[:test]}: #{test_result[:message]}"
-
end
-
-
Rails.logger.info "Sentry configuration:"
-
then: 0
else: 0
Rails.logger.info " DSN: #{result[:configuration][:dsn_configured] ? "Configured" : "Not configured"}"
-
Rails.logger.info " Environment: #{result[:configuration][:environment]}"
-
Rails.logger.info " Enabled environments: #{result[:configuration][:enabled_environments].join(", ")}"
-
end
-
end
-
end
-
# typed: false
-
-
4
class ApplicationRecord < ActiveRecord::Base
-
4
primary_abstract_class
-
-
4
include ColumnNameSyms
-
end
-
# == Schema Information
-
#
-
# Table name: anchorage_assessments
-
#
-
# id :integer not null
-
# anchor_accessories_comment :text
-
# anchor_accessories_pass :boolean
-
# anchor_degree_comment :text
-
# anchor_degree_pass :boolean
-
# anchor_type_comment :text
-
# anchor_type_pass :boolean
-
# num_high_anchors :integer
-
# num_high_anchors_comment :text
-
# num_high_anchors_pass :boolean
-
# num_low_anchors :integer
-
# num_low_anchors_comment :text
-
# num_low_anchors_pass :boolean
-
# pull_strength_comment :text
-
# pull_strength_pass :boolean
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_id :string(8) not null, primary key
-
#
-
# Indexes
-
#
-
# index_anchorage_assessments_on_inspection_id (inspection_id)
-
#
-
# Foreign Keys
-
#
-
# inspection_id (inspection_id => inspections.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class Assessments::AnchorageAssessment < ApplicationRecord
-
4
extend T::Sig
-
4
include AssessmentLogging
-
4
include AssessmentCompletion
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "inspection_id"
-
4
belongs_to :inspection
-
-
4
after_update :log_assessment_update, if: :saved_changes?
-
-
6
sig { returns(T::Boolean) }
-
4
def meets_anchor_requirements?
-
2
else: 2
unless total_anchors &&
-
inspection.width &&
-
inspection.height &&
-
then: 0
inspection.length
-
return false
-
end
-
-
2
total_anchors >= anchorage_result.value
-
end
-
-
7
sig { returns(Integer) }
-
4
def total_anchors
-
8
(num_low_anchors || 0) + (num_high_anchors || 0)
-
end
-
-
7
sig { returns(T.any(Object, NilClass)) }
-
4
def anchorage_result
-
14
@anchor_result ||= EN14960.calculate_anchors(
-
length: inspection.length.to_f,
-
width: inspection.width.to_f,
-
height: inspection.height.to_f
-
)
-
end
-
-
7
sig { returns(Integer) }
-
4
def required_anchors
-
12
then: 4
else: 8
return 0 if inspection.volume.blank?
-
8
anchorage_result.value
-
end
-
-
7
sig { returns(T::Array[T.untyped]) }
-
4
def anchorage_breakdown
-
4
else: 4
then: 0
return [] unless inspection.volume
-
4
anchorage_result.breakdown
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: enclosed_assessments
-
#
-
# id :integer not null
-
# exit_number :integer
-
# exit_number_comment :text
-
# exit_number_pass :boolean
-
# exit_sign_always_visible_comment :text
-
# exit_sign_always_visible_pass :boolean
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_id :string(8) not null, primary key
-
#
-
# Indexes
-
#
-
# index_enclosed_assessments_on_inspection_id (inspection_id)
-
#
-
# Foreign Keys
-
#
-
# inspection_id (inspection_id => inspections.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class Assessments::EnclosedAssessment < ApplicationRecord
-
4
extend T::Sig
-
4
include AssessmentLogging
-
4
include AssessmentCompletion
-
4
include ColumnNameSyms
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "inspection_id"
-
-
4
belongs_to :inspection
-
-
4
validates :inspection_id,
-
uniqueness: true
-
end
-
# == Schema Information
-
#
-
# Table name: fan_assessments
-
#
-
# id :integer not null
-
# blower_finger_comment :text
-
# blower_finger_pass :boolean
-
# blower_flap_comment :text
-
# blower_flap_pass :integer
-
# blower_serial :string
-
# blower_tube_length :decimal(8, 2)
-
# blower_tube_length_comment :text
-
# blower_tube_length_pass :boolean
-
# blower_visual_comment :text
-
# blower_visual_pass :boolean
-
# fan_size_type :text
-
# number_of_blowers :integer
-
# pat_comment :text
-
# pat_pass :integer
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_id :string(8) not null, primary key
-
#
-
# Indexes
-
#
-
# index_fan_assessments_on_inspection_id (inspection_id)
-
#
-
# Foreign Keys
-
#
-
# inspection_id (inspection_id => inspections.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class Assessments::FanAssessment < ApplicationRecord
-
4
extend T::Sig
-
4
include AssessmentLogging
-
4
include AssessmentCompletion
-
4
include ColumnNameSyms
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "inspection_id"
-
-
4
belongs_to :inspection
-
-
4
enum :pat_pass, Inspection::PASS_FAIL_NA, prefix: true
-
4
enum :blower_flap_pass, Inspection::PASS_FAIL_NA, prefix: true
-
-
4
validates :inspection_id,
-
uniqueness: true
-
end
-
# == Schema Information
-
#
-
# Table name: materials_assessments
-
#
-
# id :integer not null
-
# artwork_comment :text
-
# artwork_pass :integer
-
# fabric_strength_comment :text
-
# fabric_strength_pass :boolean
-
# fire_retardant_comment :text
-
# fire_retardant_pass :boolean
-
# retention_netting_comment :text
-
# retention_netting_pass :integer
-
# ropes :integer
-
# ropes_comment :text
-
# ropes_pass :integer
-
# thread_comment :text
-
# thread_pass :boolean
-
# windows_comment :text
-
# windows_pass :integer
-
# zips_comment :text
-
# zips_pass :integer
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_id :string(8) not null, primary key
-
#
-
# Indexes
-
#
-
# index_materials_assessments_on_inspection_id (inspection_id)
-
#
-
# Foreign Keys
-
#
-
# inspection_id (inspection_id => inspections.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class Assessments::MaterialsAssessment < ApplicationRecord
-
4
extend T::Sig
-
4
include AssessmentLogging
-
4
include AssessmentCompletion
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "inspection_id"
-
-
4
belongs_to :inspection
-
-
4
enum :ropes_pass, Inspection::PASS_FAIL_NA
-
4
enum :retention_netting_pass, Inspection::PASS_FAIL_NA, prefix: true
-
4
enum :zips_pass, Inspection::PASS_FAIL_NA, prefix: true
-
4
enum :windows_pass, Inspection::PASS_FAIL_NA, prefix: true
-
4
enum :artwork_pass, Inspection::PASS_FAIL_NA, prefix: true
-
-
4
after_update :log_assessment_update, if: :saved_changes?
-
-
4
sig { returns(T::Boolean) }
-
4
def ropes_compliant?
-
EN14960.valid_rope_diameter?(ropes)
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: slide_assessments
-
#
-
# id :integer not null
-
# clamber_netting_comment :text
-
# clamber_netting_pass :integer
-
# runout :decimal(8, 2)
-
# runout_comment :text
-
# runout_pass :boolean
-
# slide_beyond_first_metre_height :decimal(8, 2)
-
# slide_beyond_first_metre_height_comment :text
-
# slide_first_metre_height :decimal(8, 2)
-
# slide_first_metre_height_comment :text
-
# slide_permanent_roof :boolean
-
# slide_permanent_roof_comment :text
-
# slide_platform_height :decimal(8, 2)
-
# slide_platform_height_comment :text
-
# slide_wall_height :decimal(8, 2)
-
# slide_wall_height_comment :text
-
# slip_sheet_comment :text
-
# slip_sheet_pass :boolean
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_id :string(12) not null, primary key
-
#
-
# Indexes
-
#
-
# index_slide_assessments_on_inspection_id (inspection_id)
-
#
-
# Foreign Keys
-
#
-
# inspection_id (inspection_id => inspections.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class Assessments::SlideAssessment < ApplicationRecord
-
4
extend T::Sig
-
4
include AssessmentLogging
-
4
include AssessmentCompletion
-
4
include ColumnNameSyms
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "inspection_id"
-
-
4
belongs_to :inspection
-
-
4
enum :clamber_netting_pass, Inspection::PASS_FAIL_NA
-
-
6
sig { returns(T::Boolean) }
-
4
def meets_runout_requirements?
-
2
else: 2
then: 0
return false unless runout.present? && slide_platform_height.present?
-
2
EN14960::Calculators::SlideCalculator.meets_runout_requirements?(
-
runout.to_f, slide_platform_height.to_f
-
)
-
end
-
-
4
sig { returns(T.nilable(Integer)) }
-
4
def required_runout_length
-
then: 0
else: 0
return nil if slide_platform_height.blank?
-
EN14960::Calculators::SlideCalculator.calculate_runout_value(
-
slide_platform_height.to_f
-
)
-
end
-
-
4
sig { returns(String) }
-
4
def runout_compliance_status
-
then: 0
else: 0
return I18n.t("forms.slide.compliance.not_assessed") if runout.blank?
-
then: 0
if meets_runout_requirements?
-
I18n.t("forms.slide.compliance.compliant")
-
else: 0
else
-
I18n.t("forms.slide.compliance.non_compliant",
-
required: required_runout_length)
-
end
-
end
-
-
5
sig { returns(T::Boolean) }
-
4
def meets_wall_height_requirements?
-
4
else: 4
then: 0
return false unless slide_platform_height.present? &&
-
slide_wall_height.present? && !slide_permanent_roof.nil?
-
-
# Check if wall height requirements are met for all preset user heights
-
4
[1.0, 1.2, 1.5, 1.8].all? do |user_height|
-
16
EN14960::Calculators::SlideCalculator.meets_height_requirements?(
-
slide_platform_height.to_f,
-
user_height,
-
slide_wall_height.to_f,
-
slide_permanent_roof
-
)
-
end
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: structure_assessments
-
#
-
# id :integer not null
-
# air_loss_comment :text
-
# air_loss_pass :boolean
-
# critical_fall_off_height :integer
-
# critical_fall_off_height_comment :text
-
# critical_fall_off_height_pass :boolean
-
# entrapment_comment :text
-
# entrapment_pass :boolean
-
# evacuation_time_comment :text
-
# evacuation_time_pass :boolean
-
# grounding_comment :text
-
# grounding_pass :boolean
-
# markings_comment :text
-
# markings_pass :boolean
-
# platform_height :integer
-
# platform_height_comment :text
-
# platform_height_pass :boolean
-
# seam_integrity_comment :text
-
# seam_integrity_pass :boolean
-
# sharp_edges_comment :text
-
# sharp_edges_pass :boolean
-
# step_ramp_size :integer
-
# step_ramp_size_comment :text
-
# step_ramp_size_pass :boolean
-
# stitch_length_comment :text
-
# stitch_length_pass :boolean
-
# straight_walls_comment :text
-
# straight_walls_pass :boolean
-
# trough_adjacent_panel_width :integer
-
# trough_adjacent_panel_width_comment :text
-
# trough_comment :text
-
# trough_depth :integer
-
# trough_depth_comment :string(1000)
-
# trough_pass :boolean
-
# unit_pressure :decimal(8, 2)
-
# unit_pressure_comment :text
-
# unit_pressure_pass :boolean
-
# unit_stable_comment :text
-
# unit_stable_pass :boolean
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_id :string(8) not null, primary key
-
#
-
# Indexes
-
#
-
# index_structure_assessments_on_inspection_id (inspection_id)
-
#
-
# Foreign Keys
-
#
-
# inspection_id (inspection_id => inspections.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class Assessments::StructureAssessment < ApplicationRecord
-
4
extend T::Sig
-
4
include AssessmentLogging
-
4
include AssessmentCompletion
-
4
include ColumnNameSyms
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "inspection_id"
-
-
4
belongs_to :inspection
-
-
4
after_update :log_assessment_update, if: :saved_changes?
-
-
5
sig { returns(T::Boolean) }
-
4
def meets_height_requirements?
-
20
user_height = inspection.user_height_assessment
-
20
else: 20
then: 0
return false unless platform_height.present? &&
-
then: 20
else: 0
user_height&.containing_wall_height.present?
-
-
20
permanent_roof = permanent_roof_status
-
20
then: 0
else: 20
return false if permanent_roof.nil?
-
-
# Check if height requirements are met for all preset user heights
-
20
[1.0, 1.2, 1.5, 1.8].all? do |height|
-
64
EN14960::Calculators::SlideCalculator.meets_height_requirements?(
-
platform_height / 1000.0, # Convert mm to m
-
height,
-
user_height.containing_wall_height.to_f,
-
permanent_roof
-
)
-
end
-
end
-
-
4
private
-
-
5
sig { returns(T::Boolean) }
-
4
def permanent_roof_status
-
# Permanent roof only matters for platforms 3000mm and above
-
20
then: 16
else: 4
return false if platform_height < 3000
-
-
# For platforms 3.0m+, check slide assessment if inspection has a slide
-
4
else: 4
then: 0
return false unless inspection.has_slide?
-
-
4
then: 4
else: 0
inspection.slide_assessment&.slide_permanent_roof
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: user_height_assessments
-
#
-
# id :integer not null
-
# containing_wall_height :decimal(8, 2)
-
# containing_wall_height_comment :text
-
# custom_user_height_comment :text
-
# negative_adjustment :decimal(8, 2)
-
# negative_adjustment_comment :text
-
# play_area_length :decimal(8, 2)
-
# play_area_length_comment :text
-
# play_area_width :decimal(8, 2)
-
# play_area_width_comment :text
-
# users_at_1000mm :integer
-
# users_at_1200mm :integer
-
# users_at_1500mm :integer
-
# users_at_1800mm :integer
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_id :string(12) not null, primary key
-
#
-
# Indexes
-
#
-
# index_user_height_assessments_on_inspection_id (inspection_id)
-
#
-
# Foreign Keys
-
#
-
# inspection_id (inspection_id => inspections.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module Assessments
-
4
class UserHeightAssessment < ApplicationRecord
-
4
extend T::Sig
-
4
include AssessmentLogging
-
4
include AssessmentCompletion
-
4
include ColumnNameSyms
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "inspection_id"
-
-
4
belongs_to :inspection
-
-
4
validates :inspection_id,
-
uniqueness: true
-
-
5
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def validate_play_area
-
5
else: 5
then: 0
return {valid: false, errors: ["Inspection not found"]} unless inspection
-
-
5
unit_length = inspection.length
-
5
unit_width = inspection.width
-
-
# Check if we have all required measurements
-
5
then: 0
else: 5
if [unit_length, unit_width, play_area_length, play_area_width].any?(&:nil?)
-
return {
-
valid: false,
-
errors: ["Missing required measurements for play area validation"],
-
measurements: {}
-
}
-
end
-
-
# Use the negative_adjustment value, defaulting to 0 if nil
-
5
adjustment = negative_adjustment || 0
-
-
# Call the EN14960 validator - convert BigDecimal to Float
-
5
EN14960.validate_play_area(
-
unit_length: unit_length.to_f,
-
unit_width: unit_width.to_f,
-
play_area_length: play_area_length.to_f,
-
play_area_width: play_area_width.to_f,
-
negative_adjustment_area: adjustment.to_f
-
)
-
end
-
-
4
sig { returns(T::Boolean) }
-
4
def play_area_valid?
-
validate_play_area[:valid]
-
end
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module AssessmentCompletion
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
SYSTEM_FIELDS = %i[
-
id
-
inspection_id
-
created_at
-
updated_at
-
].freeze
-
-
8
sig { returns(T::Boolean) }
-
4
def complete?
-
1977
incomplete_fields.empty?
-
end
-
-
8
sig { returns(T::Array[Symbol]) }
-
4
def incomplete_fields
-
5077
(self.class.column_name_syms - SYSTEM_FIELDS)
-
86524
.reject { |f| f.end_with?("_comment") }
-
49259
.select { |f| field_is_incomplete?(f) }
-
27663
.reject { |f| field_allows_nil_when_na?(f) }
-
end
-
-
8
sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) }
-
4
def incomplete_fields_grouped
-
3091
field_to_partial = build_field_to_partial_mapping
-
3091
incomplete = incomplete_fields
-
3091
group_incomplete_fields(incomplete, field_to_partial)
-
end
-
-
4
private
-
-
8
sig { returns(T::Hash[Symbol, Symbol]) }
-
4
def build_field_to_partial_mapping
-
3091
form_config = get_form_config
-
3091
field_to_partial = {}
-
-
3091
form_config.each do |section|
-
6212
else: 6212
then: 0
next unless section[:fields]
-
6212
map_section_fields(section, field_to_partial)
-
end
-
-
3091
field_to_partial
-
end
-
-
8
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
-
4
def get_form_config
-
3091
self.class.form_fields
-
rescue
-
[]
-
end
-
-
4
sig do
-
4
params(
-
section: T::Hash[Symbol, T.untyped],
-
field_to_partial: T::Hash[Symbol, Symbol]
-
).void
-
end
-
4
def map_section_fields(section, field_to_partial)
-
6212
section[:fields].each do |field_config|
-
25985
field = field_config[:field]
-
25985
partial = field_config[:partial]
-
25985
field_to_partial[field.to_sym] = partial
-
-
# Also map composite fields
-
25985
partial_sym = partial.to_sym
-
25985
composite_fields = ChobbleForms::FieldUtils
-
.get_composite_fields(field.to_sym, partial_sym)
-
25985
composite_fields.each do |cf|
-
39626
field_to_partial[cf.to_sym] = partial
-
end
-
end
-
end
-
-
4
sig do
-
4
params(
-
incomplete: T::Array[Symbol],
-
field_to_partial: T::Hash[Symbol, Symbol]
-
).returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
-
end
-
4
def group_incomplete_fields(incomplete, field_to_partial)
-
3091
grouped = {}
-
3091
processed = Set.new
-
-
3091
incomplete.each do |field|
-
19881
then: 2959
else: 16922
next if processed.include?(field)
-
16922
process_field_group(
-
field, incomplete, field_to_partial, grouped, processed
-
)
-
end
-
-
3091
grouped
-
end
-
-
4
sig do
-
4
params(
-
field: Symbol,
-
incomplete: T::Array[Symbol],
-
field_to_partial: T::Hash[Symbol, Symbol],
-
grouped: T::Hash[Symbol, T::Hash[Symbol, T.untyped]],
-
processed: Set
-
).void
-
end
-
4
def process_field_group(
-
field, incomplete, field_to_partial, grouped, processed
-
)
-
16922
base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
-
16922
partial = field_to_partial[field] || field_to_partial[base_field]
-
-
# Find all related incomplete fields for this base
-
16922
related = incomplete.select do |f|
-
203168
ChobbleForms::FieldUtils.strip_field_suffix(f) == base_field
-
end
-
-
16922
then: 2959
else: 13963
key = (related.size > 1) ? base_field : field
-
16922
grouped[key] = {
-
fields: related,
-
partial: partial
-
}
-
16922
processed.merge(related)
-
end
-
-
8
sig { params(field: Symbol).returns(T::Boolean) }
-
4
def field_is_incomplete?(field)
-
49259
value = send(field)
-
# Field is incomplete if nil
-
49259
value.nil?
-
end
-
-
8
sig { params(field: Symbol).returns(T::Boolean) }
-
4
def field_allows_nil_when_na?(field)
-
# Pass fields are always required, even if set to "na"
-
27663
then: 16319
else: 11344
return false if field.end_with?("_pass")
-
-
# Only allow nil for value fields when corresponding _pass field is "na"
-
11344
pass_field = "#{field}_pass"
-
11344
respond_to?(pass_field) && send(pass_field) == "na"
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module AssessmentLogging
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
included do
-
28
after_update :log_assessment_update, if: :saved_changes?
-
end
-
-
4
private
-
-
8
sig { void }
-
4
def log_assessment_update
-
4708
assessment_type = self.class.name.underscore.humanize
-
4708
inspection.log_audit_action(
-
"assessment_updated",
-
inspection.user,
-
"#{assessment_type} updated"
-
)
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module ColumnNameSyms
-
4
extend T::Sig
-
4
extend ActiveSupport::Concern
-
-
4
class_methods do
-
4
extend T::Sig
-
-
8
sig { returns(T::Array[Symbol]) }
-
4
def column_name_syms
-
5314
column_names.map(&:to_sym).sort
-
end
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module CustomIdGenerator
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
# Standard ID length for all models using CustomIdGenerator
-
4
ID_LENGTH = 8
-
-
# Ambiguous characters to exclude from IDs
-
4
AMBIGUOUS_CHARS = %w[0 O 1 I L].freeze
-
-
4
included do
-
27
self.primary_key = "id"
-
5165
before_create :generate_custom_id, if: -> { id.blank? }
-
end
-
-
4
class_methods do
-
4
extend T::Sig
-
-
4
sig do
-
4
params(scope_conditions: T::Hash[T.untyped, T.untyped]).returns(String)
-
end
-
4
def generate_random_id(scope_conditions = {})
-
6530
loop do
-
6531
raw_id = SecureRandom.alphanumeric(32).upcase
-
6531
filtered_chars = raw_id.chars.reject do |char|
-
208992
AMBIGUOUS_CHARS.include?(char)
-
end
-
6531
id = filtered_chars.first(ID_LENGTH).join
-
6531
then: 0
else: 6531
next if id.length < ID_LENGTH
-
6531
else: 1
then: 6530
break id unless exists?({id: id}.merge(scope_conditions))
-
end
-
end
-
end
-
-
4
private
-
-
8
sig { void }
-
4
def generate_custom_id
-
6426
then: 1
else: 6425
scope_conditions = respond_to?(:uniqueness_scope) ? uniqueness_scope : {}
-
6426
self.id = self.class.generate_random_id(scope_conditions)
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module FormConfigurable
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
class_methods do
-
4
extend T::Sig
-
-
8
sig { params(user: T.nilable(User)).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
-
4
def form_fields(user: nil)
-
3958
@form_fields ||= load_form_config_from_yaml
-
end
-
-
8
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
-
4
def load_form_config_from_yaml
-
# Remove namespace and use just the class name
-
40
file_name = name.demodulize.underscore
-
40
config_path = Rails.root.join("config/forms/#{file_name}.yml")
-
40
yaml_content = YAML.load_file(config_path)
-
40
yaml_content["form_fields"].map do |fieldset|
-
88
fieldset = fieldset.deep_symbolize_keys
-
# Also symbolize the field and partial values
-
88
then: 88
else: 0
if fieldset[:fields]
-
88
fieldset[:fields] = fieldset[:fields].map do |field_config|
-
320
field_config[:field] = field_config[:field].to_sym
-
320
field_config[:partial] = field_config[:partial].to_sym
-
320
field_config
-
end
-
end
-
88
fieldset
-
end
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
# Shared concern for defining which fields should be excluded from public output
-
# Used by both PDF generation and JSON serialization to ensure consistency
-
4
module PublicFieldFiltering
-
4
extend ActiveSupport::Concern
-
4
include ColumnNameSyms
-
-
# System/metadata fields to exclude from public outputs (shared)
-
4
EXCLUDED_FIELDS = %i[
-
id
-
created_at
-
updated_at
-
pdf_last_accessed_at
-
user_id
-
unit_id
-
inspector_company_id
-
inspection_id
-
is_seed
-
].freeze
-
-
# Additional fields to exclude from PDFs specifically
-
4
PDF_EXCLUDED_FIELDS = %i[
-
complete_date
-
inspection_date
-
].freeze
-
-
# Fields excluded from PDFs (combines shared + PDF-specific)
-
4
PDF_TOTAL_EXCLUDED_FIELDS = (EXCLUDED_FIELDS + PDF_EXCLUDED_FIELDS).freeze
-
-
# Computed fields to exclude from public outputs
-
4
EXCLUDED_COMPUTED_FIELDS = %i[
-
reinspection_date
-
].freeze
-
-
4
class_methods do
-
4
def public_fields
-
3
column_name_syms - EXCLUDED_FIELDS
-
end
-
-
4
def excluded_fields_for_assessment(_klass_name)
-
6
EXCLUDED_FIELDS
-
end
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
module ValidationConfigurable
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
-
4
included do
-
# Apply validations when the concern is included
-
43
then: 43
else: 0
if ancestors.include?(FormConfigurable)
-
43
apply_form_validations
-
end
-
end
-
-
4
class_methods do
-
4
extend T::Sig
-
-
8
sig { void }
-
4
def apply_form_validations
-
form_config = begin
-
43
form_fields
-
rescue
-
nil
-
end
-
43
else: 43
then: 0
return unless form_config
-
-
43
form_config.each do |section|
-
91
else: 91
then: 0
next unless section[:fields]
-
-
91
section[:fields].each do |field_config|
-
329
apply_validation_for_field(field_config)
-
end
-
end
-
end
-
-
4
private
-
-
8
sig { params(field_config: T::Hash[Symbol, T.untyped]).void }
-
4
def apply_validation_for_field(field_config)
-
329
field = field_config[:field]
-
329
attributes = field_config[:attributes] || {}
-
329
partial = field_config[:partial]
-
-
329
else: 329
then: 0
return unless field
-
-
329
then: 30
else: 299
if attributes[:required]
-
30
validates field, presence: true
-
end
-
-
329
else: 255
case partial
-
when: 47
when :decimal_comment, :decimal
-
47
apply_decimal_validation(field, attributes)
-
when: 27
when :number, :number_pass_fail_na_comment
-
27
apply_number_validation(field, attributes)
-
end
-
end
-
-
8
sig { params(field: Symbol, attributes: T::Hash[Symbol, T.untyped]).void }
-
4
def apply_decimal_validation(field, attributes)
-
47
options = build_numericality_options(attributes)
-
47
validates field, numericality: options, allow_blank: true
-
end
-
-
8
sig { params(field: Symbol, attributes: T::Hash[Symbol, T.untyped]).void }
-
4
def apply_number_validation(field, attributes)
-
27
options = build_numericality_options(attributes)
-
27
options[:only_integer] = true
-
27
validates field, numericality: options, allow_blank: true
-
end
-
-
8
sig { params(attributes: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
-
4
def build_numericality_options(attributes)
-
74
options = {}
-
-
74
then: 70
else: 4
if attributes[:min]
-
70
options[:greater_than_or_equal_to] = attributes[:min]
-
end
-
-
74
then: 47
else: 27
if attributes[:max]
-
47
options[:less_than_or_equal_to] = attributes[:max]
-
end
-
-
74
options
-
end
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: credentials
-
#
-
# id :integer not null, primary key
-
# nickname :string not null
-
# public_key :string not null
-
# sign_count :integer default(0), not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# external_id :string not null
-
# user_id :string(12) not null
-
#
-
# Indexes
-
#
-
# index_credentials_on_external_id (external_id) UNIQUE
-
# index_credentials_on_user_id (user_id)
-
#
-
# Foreign Keys
-
#
-
# user_id (user_id => users.id)
-
#
-
4
class Credential < ApplicationRecord
-
4
belongs_to :user
-
-
4
validates :external_id, :public_key, :nickname, :sign_count, presence: true
-
4
validates :external_id, uniqueness: true
-
4
validates :sign_count,
-
numericality: {
-
only_integer: true,
-
greater_than_or_equal_to: 0,
-
4
less_than_or_equal_to: (2**32) - 1
-
}
-
end
-
# == Schema Information
-
#
-
# Table name: events
-
#
-
# id :integer not null, primary key
-
# action :string not null
-
# changed_data :json
-
# details :text
-
# metadata :json
-
# resource_type :string not null
-
# created_at :datetime not null
-
# resource_id :string(12)
-
# user_id :string(12) not null
-
#
-
# Indexes
-
#
-
# index_events_on_action (action)
-
# index_events_on_created_at (created_at)
-
# index_events_on_resource_type_and_resource_id (resource_type,resource_id)
-
# index_events_on_user_id (user_id)
-
# index_events_on_user_id_and_created_at (user_id,created_at)
-
#
-
# Foreign Keys
-
#
-
# user_id (user_id => users.id)
-
#
-
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class Event < ApplicationRecord
-
4
extend T::Sig
-
-
4
belongs_to :user
-
4
belongs_to :resource, polymorphic: true, optional: true
-
-
4
validates :action, presence: true
-
4
validates :resource_type, presence: true
-
4
validates :resource_id, presence: true,
-
4817
unless: -> { resource_type == "System" }
-
-
# Scopes for common queries
-
9
scope :recent, -> { order(created_at: :desc) }
-
4
scope :for_user, ->(user) { where(user: user) }
-
9
scope :for_resource, ->(resource) { where(resource: resource) }
-
4
scope :by_action, ->(action) { where(action: action) }
-
4
scope :today, -> { where(created_at: Date.current.all_day) }
-
4
scope :this_week, -> { where(created_at: Date.current.all_week) }
-
-
# Helper to create events easily
-
4
sig do
-
4
params(
-
user: User,
-
action: String,
-
resource: ActiveRecord::Base,
-
details: T.nilable(String),
-
changed_data: T.nilable(T::Hash[String, T.any(String, Integer, T::Boolean, NilClass)]),
-
metadata: T.nilable(T::Hash[String, T.any(String, Integer, T::Boolean, NilClass)])
-
).returns(Event)
-
end
-
4
def self.log(user:, action:, resource:, details: nil,
-
changed_data: nil, metadata: nil)
-
4802
create!(
-
user: user,
-
action: action,
-
resource_type: resource.class.name,
-
resource_id: resource.id,
-
details: details,
-
changed_data: changed_data,
-
metadata: metadata
-
)
-
end
-
-
# Helper for system events that don't have a specific resource
-
4
sig do
-
3
params(
-
user: User,
-
action: String,
-
details: String,
-
metadata: T.nilable(T::Hash[String, T.any(String, Integer, T::Boolean, NilClass)])
-
).returns(Event)
-
end
-
4
def self.log_system_event(user:, action:, details:, metadata: nil)
-
11
create!(
-
user: user,
-
action: action,
-
resource_type: "System",
-
resource_id: nil,
-
details: details,
-
metadata: metadata
-
)
-
end
-
-
# Formatted description for display
-
4
sig { returns(String) }
-
4
def description
-
details || "#{user.email} #{action} #{resource_type} #{resource_id}"
-
end
-
-
# Check if the event was triggered by a specific user
-
4
sig { params(check_user: User).returns(T::Boolean) }
-
4
def triggered_by?(check_user)
-
user == check_user
-
end
-
-
# Get the resource object if it still exists
-
5
sig { returns(T.nilable(ActiveRecord::Base)) }
-
4
def resource_object
-
2
else: 2
then: 0
return nil unless resource_type && resource_id
-
2
resource_type.constantize.find_by(id: resource_id)
-
rescue NameError
-
1
nil
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
# == Schema Information
-
#
-
# Table name: inspections
-
#
-
# id :string(8) not null, primary key
-
# complete_date :datetime
-
# has_slide :boolean
-
# height :decimal(8, 2)
-
# height_comment :string(1000)
-
# indoor_only :boolean
-
# inspection_date :datetime
-
# inspection_type :string default("bouncy_castle"), not null
-
# is_seed :boolean default(FALSE), not null
-
# is_totally_enclosed :boolean
-
# length :decimal(8, 2)
-
# length_comment :string(1000)
-
# passed :boolean
-
# pdf_last_accessed_at :datetime
-
# risk_assessment :text
-
# width :decimal(8, 2)
-
# width_comment :string(1000)
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspector_company_id :string(8)
-
# unit_id :string(8)
-
# user_id :string(8) not null
-
#
-
# Indexes
-
#
-
# index_inspections_on_inspection_type (inspection_type)
-
# index_inspections_on_inspector_company_id (inspector_company_id)
-
# index_inspections_on_is_seed (is_seed)
-
# index_inspections_on_unit_id (unit_id)
-
# index_inspections_on_user_id (user_id)
-
#
-
# Foreign Keys
-
#
-
# inspector_company_id (inspector_company_id => inspector_companies.id)
-
# unit_id (unit_id => units.id)
-
# user_id (user_id => users.id)
-
#
-
4
class Inspection < ApplicationRecord
-
4
extend T::Sig
-
-
4
include CustomIdGenerator
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
PASS_FAIL_NA = {fail: 0, pass: 1, na: 2}.freeze
-
-
4
enum :inspection_type, {
-
bouncy_castle: "BOUNCY_CASTLE",
-
bouncing_pillow: "BOUNCING_PILLOW"
-
}
-
-
CASTLE_ASSESSMENT_TYPES = {
-
4
user_height_assessment: Assessments::UserHeightAssessment,
-
slide_assessment: Assessments::SlideAssessment,
-
structure_assessment: Assessments::StructureAssessment,
-
anchorage_assessment: Assessments::AnchorageAssessment,
-
materials_assessment: Assessments::MaterialsAssessment,
-
enclosed_assessment: Assessments::EnclosedAssessment,
-
fan_assessment: Assessments::FanAssessment
-
}.freeze
-
-
PILLOW_ASSESSMENT_TYPES = {
-
4
fan_assessment: Assessments::FanAssessment
-
}.freeze
-
-
ALL_ASSESSMENT_TYPES =
-
4
CASTLE_ASSESSMENT_TYPES.merge(PILLOW_ASSESSMENT_TYPES).freeze
-
-
4
USER_EDITABLE_PARAMS = %i[
-
has_slide
-
height
-
indoor_only
-
inspection_date
-
is_totally_enclosed
-
length
-
passed
-
photo_1
-
photo_2
-
photo_3
-
risk_assessment
-
unit_id
-
width
-
].freeze
-
-
REQUIRED_TO_COMPLETE_FIELDS =
-
4
USER_EDITABLE_PARAMS - %i[
-
risk_assessment
-
]
-
-
4
belongs_to :user
-
4
belongs_to :unit, optional: true
-
4
belongs_to :inspector_company, optional: true
-
-
4
has_one_attached :photo_1
-
4
has_one_attached :photo_2
-
4
has_one_attached :photo_3
-
4
has_one_attached :cached_pdf
-
4
validate :photos_must_be_images
-
-
4
before_validation :set_inspector_company_from_user, on: :create
-
4
before_validation :set_inspection_type_from_unit, on: :create
-
-
4
after_update :invalidate_pdf_cache
-
4
after_save :invalidate_unit_pdf_cache
-
-
4
ALL_ASSESSMENT_TYPES.each do |assessment_name, assessment_class|
-
28
has_one assessment_name,
-
class_name: assessment_class.name,
-
dependent: :destroy
-
end
-
-
# Accept nested attributes for all assessments
-
4
accepts_nested_attributes_for(*ALL_ASSESSMENT_TYPES.keys)
-
-
# Override assessment getters to auto-create if missing
-
4
ALL_ASSESSMENT_TYPES.each do |assessment_name, assessment_class|
-
# Auto-create version
-
28
define_method(assessment_name) do
-
14980
super() || assessment_class.find_or_create_by!(inspection: self)
-
end
-
-
# Non-creating version for safe navigation
-
28
define_method("#{assessment_name}?") do
-
7
then: 3
if association(assessment_name).loaded?
-
3
send(assessment_name)
-
else: 4
else
-
4
assessment_class.find_by(inspection: self)
-
end
-
end
-
end
-
-
4
validates :inspection_date, presence: true
-
-
# Scopes
-
69
scope :seed_data, -> { where(is_seed: true) }
-
8
scope :non_seed_data, -> { where(is_seed: false) }
-
8
scope :passed, -> { where(passed: true) }
-
7
scope :failed, -> { where(passed: false) }
-
558
scope :complete, -> { where.not(complete_date: nil) }
-
6
scope :draft, -> { where(complete_date: nil) }
-
4
scope :search, lambda { |query|
-
425
then: 0
if query.present?
-
joins("LEFT JOIN units ON units.id = inspections.unit_id")
-
.where(search_conditions, *search_values(query))
-
else: 425
else
-
425
all
-
end
-
}
-
4
scope :filter_by_result, lambda { |result|
-
431
when: 10
else: 417
case result
-
10
when: 4
when "passed" then where(passed: true)
-
4
when "failed" then where(passed: false)
-
end
-
}
-
4
scope :filter_by_unit, lambda { |unit_id|
-
428
then: 3
else: 425
where(unit_id: unit_id) if unit_id.present?
-
}
-
4
scope :filter_by_operator, lambda { |operator|
-
425
then: 0
if operator.present?
-
joins(:unit).where(units: {operator: operator})
-
else: 425
else
-
425
all
-
end
-
}
-
4
scope :filter_by_date_range, lambda { |start_date, end_date|
-
range = start_date..end_date
-
then: 0
else: 0
where(inspection_date: range) if both_dates_present?(start_date, end_date)
-
}
-
4
scope :overdue, -> { where("inspection_date < ?", Time.zone.today - 1.year) }
-
-
# Helper methods for scopes
-
4
sig { returns(String) }
-
4
def self.search_conditions
-
"inspections.id LIKE ? OR units.serial LIKE ? OR " \
-
"units.manufacturer LIKE ? OR units.name LIKE ?"
-
end
-
-
4
sig { params(query: String).returns(T::Array[String]) }
-
4
def self.search_values(query) = Array.new(4) { "%#{query}%" }
-
-
4
sig { params(start_date: T.nilable(T.any(String, Date)), end_date: T.nilable(T.any(String, Date))).returns(T::Boolean) }
-
4
def self.both_dates_present?(start_date, end_date) =
-
start_date.present? && end_date.present?
-
-
# Calculated fields
-
8
sig { returns(T.nilable(Date)) }
-
4
def reinspection_date
-
248
then: 16
else: 232
return nil if inspection_date.blank?
-
-
232
(inspection_date + 1.year).to_date
-
end
-
-
4
sig { returns(T.nilable(Numeric)) }
-
4
def area
-
else: 0
then: 0
return nil unless width && length
-
-
width * length
-
end
-
-
7
sig { returns(T.nilable(Numeric)) }
-
4
def volume
-
16
else: 12
then: 4
return nil unless width && length && height
-
-
12
width * length * height
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def complete?
-
1632
complete_date.present?
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
-
4
def assessment_types
-
165
then: 0
else: 165
bouncing_pillow? ? PILLOW_ASSESSMENT_TYPES : CASTLE_ASSESSMENT_TYPES
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
-
4
def applicable_assessments
-
2102
then: 101
if bouncing_pillow?
-
101
pillow_applicable_assessments
-
else: 2001
else
-
2001
castle_applicable_assessments
-
end
-
end
-
-
4
private
-
-
8
sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
-
4
def castle_applicable_assessments
-
2001
CASTLE_ASSESSMENT_TYPES.select do |assessment_key, _|
-
14007
case assessment_key
-
when: 2001
when :slide_assessment
-
2001
has_slide?
-
when: 2001
when :enclosed_assessment
-
2001
is_totally_enclosed?
-
when: 2001
when :anchorage_assessment
-
2001
!indoor_only?
-
else: 8004
else
-
8004
true
-
end
-
end
-
end
-
-
5
sig { returns(T::Hash[Symbol, T.class_of(ApplicationRecord)]) }
-
4
def pillow_applicable_assessments
-
101
PILLOW_ASSESSMENT_TYPES
-
end
-
-
4
public
-
-
# Iterate over only applicable assessments with a block
-
8
sig { params(block: T.proc.params(assessment_key: Symbol, assessment_class: T.class_of(ApplicationRecord), assessment: ApplicationRecord).void).void }
-
4
def each_applicable_assessment(&block)
-
768
applicable_assessments.each do |assessment_key, assessment_class|
-
3894
assessment = send(assessment_key)
-
3894
then: 3894
else: 0
yield(assessment_key, assessment_class, assessment) if block_given?
-
end
-
end
-
-
# Check if a specific assessment is applicable
-
8
sig { params(assessment_key: Symbol).returns(T::Boolean) }
-
4
def assessment_applicable?(assessment_key)
-
306
applicable_assessments.key?(assessment_key)
-
end
-
-
# Returns tabs in the order they appear in the UI
-
8
sig { returns(T::Array[String]) }
-
4
def applicable_tabs
-
893
tabs = ["inspection"]
-
-
# Get applicable assessments for this inspection type
-
6479
applicable = applicable_assessments.keys.map { |k| k.to_s.chomp("_assessment") }
-
-
# Add tabs in the correct UI order
-
893
ordered_tabs = %w[user_height slide structure anchorage materials fan enclosed]
-
893
ordered_tabs.each do |tab|
-
6251
then: 5586
else: 665
tabs << tab if applicable.include?(tab)
-
end
-
-
# Add results tab at the end
-
893
tabs << "results"
-
-
893
tabs
-
end
-
-
# Advanced methods
-
8
sig { returns(T::Boolean) }
-
4
def can_be_completed?
-
120
unit.present? &&
-
all_assessments_complete? &&
-
!passed.nil? &&
-
inspection_date.present? &&
-
width.present? &&
-
length.present? &&
-
height.present? &&
-
!has_slide.nil? &&
-
!is_totally_enclosed.nil? &&
-
!indoor_only.nil?
-
end
-
-
4
sig { returns(T::Hash[Symbol, T.any(T::Boolean, T::Array[String])]) }
-
4
def completion_status
-
complete = complete?
-
all_assessments_complete = all_assessments_complete?
-
missing_assessments = get_missing_assessments
-
can_be_completed = can_be_completed?
-
-
{
-
complete:,
-
all_assessments_complete:,
-
missing_assessments:,
-
can_be_completed:
-
}
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def can_mark_complete? = can_be_completed?
-
-
5
sig { returns(T::Array[String]) }
-
4
def completion_errors
-
22
errors = []
-
22
then: 0
else: 22
errors << "Unit is required" if unit.blank?
-
-
# Get detailed incomplete field information
-
22
incomplete_tabs = incomplete_fields
-
-
22
incomplete_tabs.each do |tab_info|
-
32
tab_name = tab_info[:name]
-
272
incomplete_field_names = tab_info[:fields].map { |f| f[:label] }.join(", ")
-
32
errors << "#{tab_name}: #{incomplete_field_names}"
-
end
-
-
22
errors
-
end
-
-
5
sig { returns(T::Array[String]) }
-
4
def get_missing_assessments
-
2
missing = []
-
-
# Check for missing unit first
-
2
then: 1
else: 1
missing << "Unit" if unit.blank?
-
-
# Check for missing assessments using the new helper
-
2
each_applicable_assessment do |assessment_key, _, assessment|
-
14
then: 14
else: 0
then: 0
else: 14
next if assessment&.complete?
-
-
# Get the assessment type without "_assessment" suffix
-
14
assessment_type = assessment_key.to_s.sub("_assessment", "")
-
# Get the name from the form header
-
14
missing << I18n.t("forms.#{assessment_type}.header")
-
end
-
-
2
missing
-
end
-
-
7
sig { params(user: User).void }
-
4
def complete!(user)
-
6
update!(complete_date: Time.current)
-
6
log_audit_action("completed", user, "Inspection completed")
-
end
-
-
6
sig { params(user: User).void }
-
4
def un_complete!(user)
-
3
update!(complete_date: nil)
-
3
log_audit_action("marked_incomplete", user, "Inspection completed")
-
end
-
-
6
sig { returns(T::Array[String]) }
-
4
def validate_completeness
-
5
assessment_validation_data.filter_map do |name, assessment, message|
-
# Convert the symbol name (e.g., :slide) to assessment key (e.g., :slide_assessment)
-
35
assessment_key = :"#{name}_assessment"
-
35
else: 22
then: 13
next unless assessment_applicable?(assessment_key)
-
-
22
then: 0
else: 22
message if assessment.present? && !assessment.complete?
-
end
-
end
-
-
8
sig { params(action: String, user: T.nilable(User), details: String).void }
-
4
def log_audit_action(action, user, details)
-
4716
Event.log(
-
user: user,
-
action: action,
-
resource: self,
-
details: details
-
)
-
rescue => e
-
# Fallback to logging if Event creation fails
-
Rails.logger.error("Failed to create event: #{e.message}")
-
then: 0
else: 0
Rails.logger.info("Inspection #{id}: #{action} by #{user&.email} - #{details}")
-
end
-
-
8
sig { params(form: T.any(Symbol, String), field: T.any(Symbol, String)).returns(String) }
-
4
def field_label(form, field)
-
18099
key = "forms.#{form}.fields.#{field}"
-
# Try the field as-is first
-
18099
label = I18n.t(key, default: nil)
-
# Try removing _pass and/or _comment suffixes
-
18099
then: 8911
else: 9188
if label.nil?
-
8911
base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
-
8911
label = I18n.t("forms.#{form}.fields.#{base_field}", default: nil)
-
end
-
# Try adding _pass suffix
-
18099
then: 0
else: 18099
label = I18n.t("#{key}_pass", default: nil) if label.nil? && !field.to_s.end_with?("_pass")
-
# If still not found, raise for the original key
-
18099
label || I18n.t(key)
-
end
-
-
8
sig { returns(T::Array[Symbol]) }
-
4
def inspection_tab_incomplete_fields
-
# Fields required for the inspection tab specifically (excludes passed which is on results tab)
-
879
fields = REQUIRED_TO_COMPLETE_FIELDS - [:passed]
-
879
fields
-
9669
.reject { |f| f.end_with?("_comment") }
-
9669
.select { |f| send(f).nil? }
-
end
-
-
8
sig { returns(T::Array[T::Hash[Symbol, T.any(Symbol, String, T::Array[T::Hash[Symbol, T.any(Symbol, String)]])]]) }
-
4
def incomplete_fields
-
496
output = []
-
-
# Process tabs in the same order as applicable_tabs
-
496
applicable_tabs.each do |tab|
-
4078
case tab
-
when "inspection"
-
when: 496
# Get incomplete fields for the inspection tab (excluding passed)
-
inspection_tab_fields =
-
496
inspection_tab_incomplete_fields
-
868
.map { |f| {field: f, label: field_label(:inspection, f)} }
-
-
496
then: 288
else: 208
if inspection_tab_fields.any?
-
288
output << {
-
tab: :inspection,
-
name: I18n.t("forms.inspection.header"),
-
fields: inspection_tab_fields
-
}
-
end
-
-
when "results"
-
when: 496
# Get incomplete fields for the results tab
-
496
results_fields = []
-
496
then: 176
else: 320
results_fields << {field: :passed, label: field_label(:results, :passed)} if passed.nil?
-
-
496
then: 176
else: 320
if results_fields.any?
-
176
output << {
-
tab: :results,
-
name: I18n.t("forms.results.header"),
-
fields: results_fields
-
}
-
end
-
-
else
-
else: 3086
# All other tabs are assessment tabs
-
3086
assessment_key = :"#{tab}_assessment"
-
3086
then: 3086
else: 0
assessment = send(assessment_key) if respond_to?(assessment_key)
-
-
3086
then: 3086
else: 0
if assessment
-
3086
grouped_fields = assessment.incomplete_fields_grouped
-
3086
assessment_fields = []
-
-
3086
grouped_fields.each do |base_field, info|
-
# Determine what's missing
-
16883
has_value_missing = info[:fields].include?(base_field)
-
16883
has_pass_missing = info[:fields].include?(:"#{base_field}_pass")
-
-
# Get the base label
-
16883
base_label = field_label(tab.to_sym, base_field)
-
-
# Construct the full label
-
16883
then: 2958
label = if has_value_missing && has_pass_missing
-
2958
else: 13925
"#{base_label} (+ Pass/Fail)"
-
13925
then: 0
elsif has_pass_missing
-
"#{base_label} Pass/Fail"
-
else: 13925
else
-
13925
base_label
-
end
-
-
16883
assessment_fields << {field: base_field, label: label}
-
end
-
-
3086
then: 2122
else: 964
if assessment_fields.any?
-
2122
output << {
-
tab: tab.to_sym,
-
name: I18n.t("forms.#{tab}.header"),
-
fields: assessment_fields
-
}
-
end
-
end
-
end
-
end
-
-
496
output
-
end
-
-
4
private
-
-
8
sig { void }
-
4
def set_inspector_company_from_user
-
1575
self.inspector_company_id ||= user.inspection_company_id
-
end
-
-
8
sig { void }
-
4
def set_inspection_type_from_unit
-
1575
else: 1561
then: 14
return unless unit
-
1561
else: 1561
then: 0
return unless new_record?
-
-
# Set inspection type to match unit type
-
1561
self.inspection_type = unit.unit_type
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def all_assessments_complete?
-
119
required_assessment_completions.all?
-
end
-
-
8
sig { returns(T::Array[T::Boolean]) }
-
4
def required_assessment_completions
-
119
applicable_assessments.map do |assessment_key, _|
-
697
then: 697
else: 0
send(assessment_key)&.complete?
-
end
-
end
-
-
4
sig { returns(T::Array[ApplicationRecord]) }
-
4
def all_assessments
-
applicable_assessments.map { |assessment_key, _| send(assessment_key) }
-
end
-
-
4
sig {
-
2
returns(
-
T::Array[
-
T::Array[T.any(Symbol, ActiveRecord::Base, String)]
-
]
-
)
-
}
-
4
def assessment_validation_data
-
5
assessment_types = %i[
-
anchorage
-
enclosed
-
fan
-
materials
-
slide
-
structure
-
user_height
-
]
-
-
5
assessment_types.map do |type|
-
35
assessment = send("#{type}_assessment")
-
35
message = I18n.t("inspections.validation.#{type}_incomplete")
-
35
[type, assessment, message]
-
end
-
end
-
-
8
sig { void }
-
4
def photos_must_be_images
-
1724
[[:photo_1, photo_1], [:photo_2, photo_2], [:photo_3, photo_3]].each do |field_name, photo|
-
5172
else: 12
then: 5160
next unless photo.attached?
-
-
# Check if blob exists and has content_type
-
12
then: 0
else: 12
if photo.blob && !photo.blob.content_type.to_s.start_with?("image/")
-
errors.add(field_name, I18n.t("activerecord.errors.messages.not_an_image"))
-
photo.purge
-
end
-
end
-
end
-
-
8
sig { void }
-
4
def invalidate_pdf_cache
-
# Skip cache invalidation if only pdf_last_accessed_at or updated_at changed
-
124
changed_attrs = saved_changes.keys
-
124
ignorable_attrs = ["pdf_last_accessed_at", "updated_at"]
-
-
124
then: 57
else: 67
return if (changed_attrs - ignorable_attrs).empty?
-
-
67
PdfCacheService.invalidate_inspection_cache(self)
-
end
-
-
8
sig { void }
-
4
def invalidate_unit_pdf_cache
-
1695
then: 1681
else: 14
PdfCacheService.invalidate_unit_cache(unit) if unit
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
# == Schema Information
-
#
-
# Table name: inspector_companies
-
#
-
# id :string(8) not null, primary key
-
# active :boolean default(TRUE)
-
# address :text not null
-
# city :string
-
# country :string default("UK")
-
# email :string
-
# name :string not null
-
# notes :text
-
# phone :string not null
-
# postal_code :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
#
-
# Indexes
-
#
-
# index_inspector_companies_on_active (active)
-
#
-
4
class InspectorCompany < ApplicationRecord
-
4
extend T::Sig
-
-
4
include CustomIdGenerator
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
has_many :inspections, dependent: :destroy
-
-
# Override to filter admin-only fields
-
4
sig {
-
2
params(user: T.nilable(User)).returns(
-
T::Array[
-
T::Hash[
-
Symbol,
-
T.any(
-
String,
-
T::Array[T::Hash[Symbol, T.any(String, Symbol, Integer, T::Boolean, T::Hash[Symbol, T.any(String, Integer, T::Boolean)])]]
-
)
-
]
-
]
-
)
-
}
-
4
def self.form_fields(user: nil)
-
39
fields = super
-
-
# Remove notes field unless user is admin
-
39
then: 39
else: 0
else: 39
then: 0
unless user&.admin?
-
fields.each do |fieldset|
-
fieldset[:fields].delete_if { |field| field[:field] == :notes }
-
end
-
end
-
-
39
fields
-
end
-
-
# File attachments
-
4
has_one_attached :logo
-
-
# Validations
-
4
validates :email, format: {with: URI::MailTo::EMAIL_REGEXP}, allow_blank: true
-
-
# Scopes
-
50
scope :active, -> { where(active: true) }
-
6
scope :archived, -> { where(active: false) }
-
4
scope :by_status, ->(status) {
-
19
when: 3
then: 8
else: 11
case status&.to_s
-
3
when: 2
when "active" then active
-
2
when: 2
when "archived" then archived
-
2
else: 12
when "all" then all
-
12
else all # Default to all companies when no parameter provided
-
end
-
}
-
4
scope :search_by_term, ->(term) {
-
14
then: 12
else: 2
return all if term.blank?
-
2
where("name LIKE ?", "%#{term}%")
-
}
-
-
# Callbacks
-
4
before_save :normalize_phone_number
-
-
# Methods
-
# Credentials validation moved to individual inspector level (User model)
-
-
6
sig { returns(String) }
-
4
def full_address
-
26
[address, city, postal_code].compact.join(", ")
-
end
-
-
5
sig { returns(Integer) }
-
4
def inspection_count
-
1
inspections.count
-
end
-
-
6
sig { params(limit: Integer).returns(ActiveRecord::Relation) }
-
4
def recent_inspections(limit = 10)
-
# Will be enhanced when Unit relationship is added
-
13
inspections.order(inspection_date: :desc).limit(limit)
-
end
-
-
6
sig { params(total: T.nilable(Integer), passed: T.nilable(Integer)).returns(Float) }
-
4
def pass_rate(total = nil, passed = nil)
-
15
total ||= inspections.count
-
15
passed ||= inspections.passed.count
-
15
then: 14
else: 1
return 0.0 if total == 0
-
1
(passed.to_f / total * 100).round(2)
-
end
-
-
6
sig { returns(T::Hash[Symbol, T.any(Integer, Float)]) }
-
4
def company_statistics
-
# Use group to get all counts in a single query
-
14
counts = inspections.group(:passed).count
-
14
passed_count = counts[true] || 0
-
14
failed_count = counts[false] || 0
-
14
total_count = passed_count + failed_count
-
-
{
-
14
total_inspections: total_count,
-
passed_inspections: passed_count,
-
failed_inspections: failed_count,
-
pass_rate: pass_rate(total_count, passed_count),
-
active_since: created_at.year
-
}
-
end
-
-
5
sig { returns(T.nilable(ActiveStorage::Attached::One)) }
-
4
def logo_url
-
1
then: 0
else: 1
logo.attached? ? logo : nil
-
end
-
-
4
private
-
-
8
sig { void }
-
4
def normalize_phone_number
-
1817
then: 0
else: 1817
return if phone.blank?
-
-
# Remove all non-digit characters
-
1817
self.phone = phone.gsub(/\D/, "")
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
# == Schema Information
-
#
-
# Table name: pages
-
#
-
# content :text
-
# is_snippet :boolean default(FALSE), not null
-
# link_title :string
-
# meta_description :text
-
# meta_title :string
-
# slug :string not null, primary key
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
#
-
4
class Page < ApplicationRecord
-
4
extend T::Sig
-
-
4
include FormConfigurable
-
4
include ValidationConfigurable
-
-
4
self.primary_key = "slug"
-
-
4
validates :slug, uniqueness: true
-
-
91
scope :pages, -> { where(is_snippet: false) }
-
1568
scope :snippets, -> { where(is_snippet: true) }
-
-
6
sig { returns(String) }
-
4
def to_param
-
21
slug.presence || "new"
-
end
-
-
# Returns content marked as safe for rendering
-
# Page content is admin-controlled and contains intentional HTML
-
8
sig { returns(ActiveSupport::SafeBuffer) }
-
4
def safe_content
-
84
content.to_s.html_safe
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
# == Schema Information
-
#
-
# Table name: units
-
#
-
# id :string(8) not null, primary key
-
# description :string
-
# is_seed :boolean default(FALSE), not null
-
# manufacture_date :date
-
# manufacturer :string
-
# name :string
-
# operator :string
-
# serial :string
-
# unit_type :string default("bouncy_castle"), not null
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# user_id :string(8) not null
-
#
-
# Indexes
-
#
-
# index_units_on_is_seed (is_seed)
-
# index_units_on_manufacturer_and_serial (manufacturer,serial) UNIQUE
-
# index_units_on_serial_and_user_id (serial,user_id) UNIQUE
-
# index_units_on_unit_type (unit_type)
-
# index_units_on_user_id (user_id)
-
#
-
# Foreign Keys
-
#
-
# user_id (user_id => users.id)
-
#
-
4
class Unit < ApplicationRecord
-
4
extend T::Sig
-
4
self.table_name = "units"
-
4
include CustomIdGenerator
-
-
4
enum :unit_type, {
-
bouncy_castle: "BOUNCY_CASTLE",
-
bouncing_pillow: "BOUNCING_PILLOW"
-
}
-
-
4
belongs_to :user
-
4
has_many :inspections
-
298
has_many :complete_inspections, -> { where.not(complete_date: nil) }, class_name: "Inspection"
-
108
has_many :draft_inspections, -> { where(complete_date: nil) }, class_name: "Inspection"
-
-
# File attachments
-
4
has_one_attached :photo
-
4
has_one_attached :cached_pdf
-
4
validate :photo_must_be_image
-
-
# Callbacks
-
4
before_create :generate_custom_id
-
4
after_update :invalidate_pdf_cache
-
4
before_destroy :check_complete_inspections
-
4
before_destroy :destroy_draft_inspections
-
-
# All fields are required for Units
-
4
validates :name, :serial, :description, :manufacturer, :operator, presence: true
-
4
validates :serial, uniqueness: {scope: [:user_id]}
-
-
# Scopes - enhanced from original Equipment and new Unit functionality
-
80
scope :seed_data, -> { where(is_seed: true) }
-
8
scope :non_seed_data, -> { where(is_seed: false) }
-
4
scope :search, ->(query) {
-
86
then: 9
if query.present?
-
9
search_term = "%#{query}%"
-
9
where(<<~SQL, *([search_term] * 5))
-
serial LIKE ?
-
OR name LIKE ?
-
OR description LIKE ?
-
OR manufacturer LIKE ?
-
OR operator LIKE ?
-
SQL
-
else: 77
else
-
77
all
-
end
-
}
-
89
then: 10
else: 75
scope :by_manufacturer, ->(manufacturer) { where(manufacturer: manufacturer) if manufacturer.present? }
-
70
then: 7
else: 59
scope :by_operator, ->(operator) { where(operator: operator) if operator.present? }
-
4
scope :with_recent_inspections, -> {
-
cutoff_date = EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days.ago
-
joins(:inspections)
-
.where(inspections: {inspection_date: cutoff_date..})
-
.distinct
-
}
-
-
4
scope :inspection_due, -> {
-
joins(:inspections)
-
.merge(Inspection.completed)
-
.group("units.id")
-
.having("MAX(inspections.complete_date) + INTERVAL #{EN14960::Constants::REINSPECTION_INTERVAL_DAYS} DAY <= CURRENT_DATE")
-
}
-
-
# Instance methods
-
-
8
sig { returns(T.nilable(Inspection)) }
-
4
def last_inspection
-
568
@last_inspection ||= inspections.merge(Inspection.complete).order(complete_date: :desc).first
-
end
-
-
4
sig { returns(String) }
-
4
def last_inspection_status
-
then: 0
else: 0
then: 0
else: 0
last_inspection&.passed? ? "Passed" : "Failed"
-
end
-
-
4
sig { returns(ActiveRecord::Relation) }
-
4
def inspection_history
-
inspections.includes(:user).order(inspection_date: :desc)
-
end
-
-
5
sig { returns(T.nilable(Date)) }
-
4
def next_inspection_due
-
17
else: 14
then: 3
return nil unless last_inspection
-
14
(last_inspection.inspection_date + EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days).to_date
-
end
-
-
5
sig { returns(T::Boolean) }
-
4
def inspection_overdue?
-
7
else: 6
then: 1
return false unless next_inspection_due
-
6
next_inspection_due < Date.current
-
end
-
-
5
sig { returns(String) }
-
4
def compliance_status
-
5
else: 3
then: 2
return "Never Inspected" unless last_inspection
-
-
3
then: 1
if inspection_overdue?
-
1
else: 2
"Overdue"
-
2
then: 1
elsif last_inspection.passed?
-
1
"Compliant"
-
else: 1
else
-
1
"Non-Compliant"
-
end
-
end
-
-
4
sig {
-
1
returns(T::Hash[Symbol, T.any(Integer, T.nilable(Date), T.nilable(String))])
-
}
-
4
def inspection_summary
-
{
-
1
total_inspections: inspections.count,
-
passed_inspections: inspections.passed.count,
-
failed_inspections: inspections.failed.count,
-
then: 0
else: 1
last_inspection_date: last_inspection&.inspection_date,
-
next_due_date: next_inspection_due,
-
compliance_status: compliance_status
-
}
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def deletable?
-
112
!complete_inspections.exists?
-
end
-
-
4
private
-
-
8
sig { void }
-
4
def check_complete_inspections
-
53
then: 1
else: 52
if complete_inspections.exists?
-
1
errors.add(:base, :has_complete_inspections)
-
1
throw(:abort)
-
end
-
end
-
-
8
sig { void }
-
4
def destroy_draft_inspections
-
52
draft_inspections.destroy_all
-
end
-
-
4
public
-
-
7
sig { returns(ActiveRecord::Relation) }
-
4
def self.overdue
-
# Find units where their most recent inspection is older than the interval
-
# Using Date.current instead of Date.today for Rails timezone consistency
-
8
cutoff_date = Date.current - EN14960::Constants::REINSPECTION_INTERVAL_DAYS.days
-
8
joins(:inspections)
-
.group("units.id")
-
.having("MAX(inspections.inspection_date) <= ?", cutoff_date)
-
end
-
-
4
private
-
-
4
sig { void }
-
4
def check_for_complete_inspections
-
then: 0
else: 0
if complete_inspections.exists?
-
errors.add(:base, :has_complete_inspections)
-
throw(:abort)
-
end
-
end
-
-
8
sig { void }
-
4
def photo_must_be_image
-
1353
else: 30
then: 1323
return unless photo.attached?
-
-
30
else: 30
then: 0
unless photo.blob.content_type.start_with?("image/")
-
errors.add(:photo, "must be an image file")
-
photo.purge
-
end
-
end
-
-
8
sig { void }
-
4
def invalidate_pdf_cache
-
# Skip cache invalidation if only updated_at changed
-
38
changed_attrs = saved_changes.keys
-
38
ignorable_attrs = ["updated_at"]
-
-
38
then: 29
else: 9
return if (changed_attrs - ignorable_attrs).empty?
-
-
9
PdfCacheService.invalidate_unit_cache(self)
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
require "sorbet-runtime"
-
-
# == Schema Information
-
#
-
# Table name: users
-
#
-
# id :string(8) not null, primary key
-
# active_until :date
-
# address :text
-
# country :string
-
# email :string
-
# last_active_at :datetime
-
# name :string
-
# password_digest :string
-
# phone :string
-
# postal_code :string
-
# rpii_inspector_number :string
-
# rpii_verified_date :datetime
-
# theme :string default("light")
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# inspection_company_id :string
-
# webauthn_id :string
-
#
-
# Indexes
-
#
-
# index_users_on_email (email) UNIQUE
-
# index_users_on_inspection_company_id (inspection_company_id)
-
# index_users_on_rpii_inspector_number (rpii_inspector_number) UNIQUE WHERE rpii_inspector_number IS NOT NULL
-
#
-
4
class User < ApplicationRecord
-
4
extend T::Sig
-
4
include CustomIdGenerator
-
-
# Type alias for RPII verification results
-
4
RpiiVerificationResult = T.type_alias do
-
1
T::Hash[Symbol, T.untyped]
-
end
-
-
4
has_secure_password
-
-
4
has_many :inspections, dependent: :destroy
-
4
has_many :units, dependent: :destroy
-
4
has_many :events, dependent: :destroy
-
4
has_many :user_sessions, dependent: :destroy
-
4
has_many :credentials, dependent: :destroy
-
4
has_one_attached :logo
-
4
has_one_attached :signature
-
4
validate :logo_must_be_image
-
4
validate :signature_must_be_image
-
-
4
belongs_to :inspection_company,
-
class_name: "InspectorCompany",
-
optional: true
-
-
4
validates :email,
-
presence: true,
-
uniqueness: true,
-
format: {with: URI::MailTo::EMAIL_REGEXP}
-
-
4
validates :password,
-
presence: true,
-
length: {minimum: 6},
-
if: :password_digest_changed?
-
-
4
validates :name,
-
presence: true,
-
if: :validate_name?
-
-
4
validates :rpii_inspector_number,
-
uniqueness: true,
-
allow_nil: true
-
-
4
validates :theme,
-
inclusion: {in: %w[default light dark]}
-
-
4
before_save :downcase_email
-
4
before_save :normalize_rpii_number
-
4
before_create :set_inactive_on_signup
-
-
4
after_initialize do
-
4818
self.webauthn_id ||= WebAuthn.generate_user_id
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def is_active?
-
1914
active_until.nil? || active_until > Date.current
-
end
-
-
5
sig { returns(T::Boolean) }
-
4
def can_delete_credentials?
-
1
credentials.count > 1
-
end
-
-
4
alias_method :can_create_inspection?, :is_active?
-
-
7
sig { returns(String) }
-
4
def inactive_user_message
-
47
I18n.t("users.messages.user_inactive")
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def admin?
-
1553
admin_pattern = ENV["ADMIN_EMAILS_PATTERN"]
-
1553
then: 0
else: 1553
return false if admin_pattern.blank?
-
-
begin
-
1553
regex = Regexp.new(admin_pattern)
-
1553
regex.match?(email)
-
rescue RegexpError
-
false
-
end
-
end
-
-
4
sig do
-
4
params(
-
value: T.nilable(T.any(Date, String, Time, ActiveSupport::TimeWithZone))
-
).void
-
end
-
4
def active_until=(value)
-
1814
@active_until_explicitly_set = true
-
1814
super
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def has_company?
-
26
inspection_company_id.present? || inspection_company.present?
-
end
-
-
4
sig { returns(T.nilable(String)) }
-
4
def display_phone
-
then: 0
else: 0
has_company? ? inspection_company.phone : phone
-
end
-
-
4
sig { returns(T.nilable(String)) }
-
4
def display_address
-
then: 0
else: 0
has_company? ? inspection_company.address : address
-
end
-
-
4
sig { returns(T.nilable(String)) }
-
4
def display_country
-
then: 0
else: 0
has_company? ? inspection_company.country : country
-
end
-
-
4
sig { returns(T.nilable(String)) }
-
4
def display_postal_code
-
then: 0
else: 0
has_company? ? inspection_company.postal_code : postal_code
-
end
-
-
5
sig { returns(RpiiVerificationResult) }
-
4
def verify_rpii_inspector_number
-
4
then: 0
if rpii_inspector_number.blank?
-
else: 4
return {valid: false, error: :blank_number}
-
4
then: 0
else: 4
elsif name.blank?
-
return {valid: false, error: :blank_name}
-
end
-
-
4
result = RpiiVerificationService.verify(rpii_inspector_number)
-
-
4
then: 3
if result[:valid]
-
3
handle_valid_rpii_result(result[:inspector])
-
else: 1
else
-
1
update(rpii_verified_date: nil)
-
1
{valid: false, error: :not_found}
-
end
-
end
-
-
7
sig { returns(T::Boolean) }
-
4
def rpii_verified?
-
44
rpii_verified_date.present?
-
end
-
-
5
sig { params(inspector: T::Hash[Symbol, T.untyped]).returns(RpiiVerificationResult) }
-
4
def handle_valid_rpii_result(inspector)
-
3
then: 2
if inspector[:name].present? && names_match?(name, inspector[:name])
-
2
update(rpii_verified_date: Time.current)
-
2
{valid: true, inspector: inspector}
-
else: 1
else
-
1
update(rpii_verified_date: nil)
-
1
{valid: false, error: :name_mismatch, inspector: inspector}
-
end
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def has_seed_data?
-
68
units.seed_data.exists? || inspections.seed_data.exists?
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def validate_name?
-
3314
new_record? || name_changed?
-
end
-
-
5
sig { params(user_name: T.nilable(String), inspector_name: T.nilable(String)).returns(T::Boolean) }
-
4
def names_match?(user_name, inspector_name)
-
3
normalized_user = user_name.to_s.strip.downcase
-
3
normalized_inspector = inspector_name.to_s.strip.downcase
-
-
3
then: 1
else: 2
return true if normalized_user == normalized_inspector
-
-
2
user_parts = normalized_user.split(/\s+/)
-
2
inspector_parts = normalized_inspector.split(/\s+/)
-
-
5
user_parts.all? { |part| inspector_parts.include?(part) }
-
end
-
-
8
sig { void }
-
4
def downcase_email
-
3284
self.email = email.downcase
-
end
-
-
8
sig { void }
-
4
def normalize_rpii_number
-
3284
then: 184
else: 3100
self.rpii_inspector_number = nil if rpii_inspector_number.blank?
-
end
-
-
8
sig { void }
-
4
def set_inactive_on_signup
-
1763
then: 1755
else: 8
return if instance_variable_get(:@active_until_explicitly_set)
-
-
8
self.active_until = Date.current - 1.day
-
end
-
-
8
sig { void }
-
4
def logo_must_be_image
-
3314
else: 6
then: 3308
return unless logo.attached?
-
-
6
then: 6
else: 0
return if logo.blob.content_type.start_with?("image/")
-
-
errors.add(:logo, "must be an image file")
-
logo.purge
-
end
-
-
8
sig { void }
-
4
def signature_must_be_image
-
3314
else: 2
then: 3312
return unless signature.attached?
-
-
2
then: 2
else: 0
return if signature.blob.content_type.start_with?("image/")
-
-
errors.add(:signature, "must be an image file")
-
signature.purge
-
end
-
end
-
# == Schema Information
-
#
-
# Table name: user_sessions
-
#
-
# id :integer not null, primary key
-
# ip_address :string
-
# last_active_at :datetime not null
-
# session_token :string not null
-
# user_agent :string
-
# created_at :datetime not null
-
# updated_at :datetime not null
-
# user_id :string(12) not null
-
#
-
# Indexes
-
#
-
# index_user_sessions_on_session_token (session_token) UNIQUE
-
# index_user_sessions_on_user_id (user_id)
-
# index_user_sessions_on_user_id_and_last_active_at (user_id,last_active_at)
-
#
-
# Foreign Keys
-
#
-
# user_id (user_id => users.id)
-
#
-
-
4
class UserSession < ApplicationRecord
-
4
belongs_to :user
-
-
4
validates :session_token, presence: true, uniqueness: true
-
4
validates :last_active_at, presence: true
-
-
4
before_validation :generate_session_token, on: :create
-
-
30
scope :active, -> { where("last_active_at > ?", 30.days.ago) }
-
30
scope :recent, -> { order(last_active_at: :desc) }
-
-
4
def active? = last_active_at > 30.days.ago
-
-
4
def touch_last_active
-
1445
update_column(:last_active_at, Time.current)
-
end
-
-
4
private
-
-
4
def generate_session_token
-
614
self.session_token ||= SecureRandom.urlsafe_base64(32)
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class BaseAssessmentBlueprint < Blueprinter::Base
-
# Define public fields from model columns excluding system fields
-
4
def self.public_fields_for(klass)
-
klass.column_name_syms - PublicFieldFiltering::EXCLUDED_FIELDS
-
end
-
-
# Use transformer to format dates consistently
-
4
transform JsonDateTransformer
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class InspectionBlueprint < Blueprinter::Base
-
# Define public fields dynamically to avoid database access at load time
-
4
def self.define_public_fields
-
28
then: 25
else: 3
return if @fields_defined
-
-
3
Inspection.column_name_syms.each do |column|
-
66
then: 24
else: 42
next if PublicFieldFiltering::EXCLUDED_FIELDS.include?(column)
-
-
42
then: 6
if %i[inspection_date complete_date].include?(column)
-
6
field column do |inspection|
-
56
value = inspection.send(column)
-
56
then: 53
else: 3
value&.strftime(JsonDateTransformer::API_DATE_FORMAT)
-
end
-
else: 36
else
-
36
field column
-
end
-
end
-
3
@fields_defined = true
-
end
-
-
# Override render to ensure fields are defined
-
4
def self.render(object, options = {})
-
28
define_public_fields
-
28
super
-
end
-
-
4
field :complete do |inspection|
-
28
inspection.complete?
-
end
-
-
4
field :passed do |inspection|
-
then: 0
else: 0
inspection.passed? if inspection.complete?
-
end
-
-
4
field :inspector do |inspection|
-
{
-
28
name: inspection.user.name,
-
rpii_inspector_number: inspection.user.rpii_inspector_number
-
}
-
end
-
-
4
field :urls do |inspection|
-
28
base_url = ENV["BASE_URL"]
-
{
-
28
report_pdf: "#{base_url}/inspections/#{inspection.id}.pdf",
-
report_json: "#{base_url}/inspections/#{inspection.id}.json",
-
qr_code: "#{base_url}/inspections/#{inspection.id}.png"
-
}
-
end
-
-
4
field :unit do |inspection|
-
28
then: 28
else: 0
if inspection.unit
-
{
-
28
id: inspection.unit.id,
-
name: inspection.unit.name,
-
serial: inspection.unit.serial,
-
manufacturer: inspection.unit.manufacturer,
-
operator: inspection.unit.operator
-
}
-
end
-
end
-
-
4
field :assessments do |inspection|
-
28
assessments = {}
-
28
inspection.each_applicable_assessment do |key, klass, assessment|
-
184
else: 184
then: 0
next unless assessment
-
-
184
assessment_data = {}
-
-
public_fields =
-
184
klass.column_name_syms -
-
PublicFieldFiltering::EXCLUDED_FIELDS
-
-
184
public_fields.each do |field|
-
3130
value = assessment.send(field)
-
3130
else: 554
then: 2576
assessment_data[field] = value unless value.nil?
-
end
-
-
184
assessments[key] = assessment_data
-
end
-
28
assessments
-
end
-
-
# Use transformer to format dates
-
4
transform JsonDateTransformer
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class JsonDateTransformer < Blueprinter::Transformer
-
# ISO 8601 date format for JSON API responses
-
4
API_DATE_FORMAT = "%Y-%m-%d"
-
-
4
def transform(hash, _object, _options)
-
52
transform_value(hash)
-
end
-
-
4
def transform_value(value)
-
4242
case value
-
when: 428
when Hash
-
4585
value.transform_values { |v| transform_value(v) }
-
when: 7
when Array
-
17
value.map { |v| transform_value(v) }
-
when: 17
when Date, Time, DateTime
-
17
value.strftime(API_DATE_FORMAT)
-
when String
-
when: 2116
# Handle string timestamps from ActiveRecord
-
2116
then: 0
if /\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.match?(value)
-
value.split(" ").first # Extract just the date part
-
else: 2116
else
-
2116
value
-
end
-
else: 1674
else
-
1674
value
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class UnitBlueprint < Blueprinter::Base
-
# Define public fields dynamically to avoid database access at load time
-
4
def self.define_public_fields
-
24
then: 21
else: 3
return if @fields_defined
-
-
3
Unit.column_name_syms.each do |column|
-
36
then: 15
else: 21
next if PublicFieldFiltering::EXCLUDED_FIELDS.include?(column)
-
-
21
then: 3
if %i[manufacture_date].include?(column)
-
3
field column do |unit|
-
24
value = unit.send(column)
-
24
then: 24
else: 0
value&.strftime(JsonDateTransformer::API_DATE_FORMAT)
-
end
-
else: 18
else
-
18
field column
-
end
-
end
-
3
@fields_defined = true
-
end
-
-
# Override render to ensure fields are defined
-
4
def self.render(object, options = {})
-
24
define_public_fields
-
24
super
-
end
-
-
# Add URLs (available in all views)
-
4
field :urls do |unit|
-
24
base_url = ENV["BASE_URL"]
-
{
-
24
report_pdf: "#{base_url}/units/#{unit.id}.pdf",
-
report_json: "#{base_url}/units/#{unit.id}.json",
-
qr_code: "#{base_url}/units/#{unit.id}.png"
-
}
-
end
-
-
# Override render to handle inspection fields conditionally
-
4
def self.render_with_inspections(unit)
-
23
json = JSON.parse(render(unit, view: :default), symbolize_names: true)
-
-
23
completed = unit.inspections.complete.order(inspection_date: :desc)
-
-
23
then: 7
else: 16
if completed.any?
-
7
json[:inspection_history] = completed.map do |inspection|
-
{
-
10
inspection_date: inspection.inspection_date,
-
passed: inspection.passed,
-
complete: inspection.complete?,
-
then: 10
else: 0
inspector_company: inspection.inspector_company&.name
-
}
-
end
-
7
json[:total_inspections] = completed.count
-
7
then: 7
else: 0
json[:last_inspection_date] = completed.first&.inspection_date
-
7
then: 7
else: 0
json[:last_inspection_passed] = completed.first&.passed
-
end
-
-
# Apply date transformation
-
23
transformer = JsonDateTransformer.new
-
23
json = transformer.transform_value(json)
-
-
23
json.to_json
-
end
-
-
# Use transformer to format dates
-
4
transform JsonDateTransformer
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
module S3BackupOperations
-
4
extend ActiveSupport::Concern
-
4
extend T::Sig
-
4
extend T::Helpers
-
-
4
requires_ancestor { Kernel }
-
-
4
private
-
-
5
sig { returns(String) }
-
4
def backup_dir = "db_backups"
-
-
5
sig { returns(Integer) }
-
4
def backup_retention_days = 60
-
-
5
sig { returns(Pathname) }
-
4
def temp_dir = Rails.root.join("tmp/backups")
-
-
5
sig { returns(T.any(String, Pathname)) }
-
4
def database_path
-
2
db_config = Rails.configuration.database_configuration[Rails.env]
-
-
# Handle multi-database configuration
-
2
then: 1
else: 1
db_config = db_config["primary"] if db_config.is_a?(Hash) && db_config.key?("primary")
-
-
2
else: 1
then: 1
raise "Database not configured for #{Rails.env}" unless db_config["database"]
-
-
1
path = db_config["database"]
-
1
then: 0
else: 1
path.start_with?("/") ? path : Rails.root.join(path)
-
end
-
-
5
sig { params(timestamp: String).returns(Pathname) }
-
4
def create_tar_gz(timestamp)
-
1
backup_filename = "database-#{timestamp}.sqlite3"
-
1
compressed_filename = "database-#{timestamp}.tar.gz"
-
1
source_path = temp_dir.join(backup_filename)
-
1
dest_path = temp_dir.join(compressed_filename)
-
-
1
dir_name = File.dirname(source_path)
-
1
base_name = File.basename(source_path)
-
-
1
system("tar", "-czf", dest_path.to_s, "-C", dir_name.to_s,
-
base_name.to_s, exception: true)
-
-
1
dest_path
-
end
-
-
5
sig { params(service: T.untyped).returns(Integer) }
-
4
def cleanup_old_backups(service)
-
1
bucket = service.send(:bucket)
-
1
cutoff_date = Time.current - backup_retention_days.days
-
1
deleted_count = 0
-
-
1
bucket.objects(prefix: "#{backup_dir}/").each do |object|
-
3
else: 2
then: 1
next unless object.key.match?(/database-\d{4}-\d{2}-\d{2}\.tar\.gz$/)
-
-
2
then: 1
else: 1
if object.last_modified < cutoff_date
-
1
service.delete(object.key)
-
1
deleted_count += 1
-
end
-
end
-
-
1
deleted_count
-
end
-
end
-
# frozen_string_literal: true
-
-
4
module S3Helpers
-
4
extend ActiveSupport::Concern
-
-
4
private
-
-
4
def ensure_s3_enabled
-
else: 0
then: 0
raise "S3 storage is not enabled" unless ENV["USE_S3_STORAGE"] == "true"
-
end
-
-
4
def validate_s3_config
-
required_vars = %w[S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET]
-
missing_vars = required_vars.select { |var| ENV[var].blank? }
-
-
then: 0
else: 0
raise "Missing S3 config: #{missing_vars.join(", ")}" if missing_vars.any?
-
end
-
-
4
def get_s3_service = ActiveStorage::Blob.service
-
end
-
4
class ImageProcessorService
-
4
FULL_SIZE = 1200
-
4
THUMBNAIL_SIZE = 200
-
4
DEFAULT_SIZE = 800
-
-
4
def self.thumbnail(image)
-
3
then: 3
else: 0
else: 2
then: 1
return nil unless image&.attached?
-
-
2
image.variant(
-
format: :jpeg,
-
resize_to_limit: [THUMBNAIL_SIZE, THUMBNAIL_SIZE],
-
saver: {quality: 75}
-
)
-
end
-
-
4
def self.default(image)
-
4
then: 4
else: 0
else: 3
then: 1
return nil unless image&.attached?
-
-
3
image.variant(
-
format: :jpeg,
-
resize_to_limit: [DEFAULT_SIZE, DEFAULT_SIZE],
-
saver: {quality: 75}
-
)
-
end
-
-
# Calculate actual dimensions after resize_to_limit transformation
-
# Pass in metadata hash with "width" and "height" keys
-
# Size can be :full, :thumbnail, or :default (defaults to :default)
-
4
def self.calculate_dimensions(metadata, size = :default)
-
5
max_size = max_size_for(size)
-
5
original_width = metadata["width"].to_f
-
5
original_height = metadata["height"].to_f
-
-
5
resize_dimensions(original_width, original_height, max_size)
-
end
-
-
4
def self.max_size_for(size)
-
5
when: 1
case size
-
1
when: 2
when :full then FULL_SIZE
-
2
else: 2
when :thumbnail then THUMBNAIL_SIZE
-
2
else DEFAULT_SIZE
-
end
-
end
-
-
4
def self.resize_dimensions(original_width, original_height, max_size)
-
5
ratio = max_size / [original_width, original_height].max
-
-
5
then: 4
if ratio < 1
-
{
-
8
width: (original_width * ratio).round,
-
4
height: (original_height * ratio).round
-
}
-
else: 1
else
-
1
{width: original_width.to_i, height: original_height.to_i}
-
end
-
end
-
end
-
# typed: strict
-
-
4
require "sorbet-runtime"
-
-
4
class InspectionCreationService
-
4
extend T::Sig
-
-
# Define custom type for service results
-
4
ServiceResult = T.type_alias do
-
4
T::Hash[Symbol, T.untyped]
-
end
-
-
8
sig { params(user: User, params: T::Hash[Symbol, T.untyped]).void }
-
4
def initialize(user, params = {})
-
20
@user = user
-
20
@unit_id = params[:unit_id]
-
end
-
-
8
sig { returns(ServiceResult) }
-
4
def create
-
20
then: 17
else: 3
unit = find_and_validate_unit if @unit_id.present?
-
20
then: 1
else: 19
return invalid_unit_result if @unit_id.present? && unit.nil?
-
-
19
inspection = build_inspection(unit)
-
-
19
then: 18
if inspection.save
-
18
notify_if_production(inspection)
-
18
success_result(inspection, unit)
-
else: 1
else
-
1
failure_result(inspection)
-
end
-
end
-
-
4
private
-
-
8
sig { returns(T.nilable(Unit)) }
-
4
def find_and_validate_unit
-
17
@user.units.find_by(id: @unit_id)
-
end
-
-
4
COPY_FROM_LAST_INSPECTION_FIELDS = T.let(
-
%i[
-
has_slide
-
is_totally_enclosed
-
length
-
width
-
height
-
].freeze,
-
T::Array[Symbol]
-
)
-
-
8
sig { params(unit: T.nilable(Unit)).returns(Inspection) }
-
4
def build_inspection(unit)
-
19
then: 16
else: 3
last_inspection = unit&.last_inspection
-
19
copy_fields = {}
-
19
then: 5
else: 14
if last_inspection
-
5
copy_fields = COPY_FROM_LAST_INSPECTION_FIELDS.map do |field|
-
25
[field, last_inspection.send(field)]
-
end.to_h
-
end
-
-
19
@user.inspections.build(
-
unit: unit,
-
inspection_date: Date.current,
-
inspector_company_id: @user.inspection_company_id,
-
**copy_fields
-
)
-
end
-
-
8
sig { params(inspection: Inspection).void }
-
4
def notify_if_production(inspection)
-
18
else: 1
then: 17
return unless Rails.env.production?
-
1
NtfyService.notify("new inspection by #{@user.email}")
-
end
-
-
5
sig { returns(ServiceResult) }
-
4
def invalid_unit_result
-
1
{
-
success: false,
-
error_type: :invalid_unit,
-
message: I18n.t("inspections.errors.invalid_unit"),
-
redirect_path: "/"
-
}
-
end
-
-
8
sig { params(inspection: Inspection, unit: T.nilable(Unit)).returns(ServiceResult) }
-
4
def success_result(inspection, unit)
-
18
then: 2
else: 16
message_key = unit.nil? ? "created_without_unit" : "created"
-
18
{
-
success: true,
-
inspection: inspection,
-
message: I18n.t("inspections.messages.#{message_key}"),
-
redirect_path: "/inspections/#{inspection.id}/edit"
-
}
-
end
-
-
5
sig { params(inspection: Inspection).returns(ServiceResult) }
-
4
def failure_result(inspection)
-
1
error_messages = inspection.errors.full_messages.join(", ")
-
1
redirect_path = build_failure_redirect_path(inspection)
-
-
1
{
-
success: false,
-
error_type: :validation_failed,
-
message: I18n.t("inspections.errors.creation_failed",
-
errors: error_messages),
-
redirect_path: redirect_path
-
}
-
end
-
-
5
sig { params(inspection: Inspection).returns(String) }
-
4
def build_failure_redirect_path(inspection)
-
1
then: 0
else: 1
inspection.unit.present? ? "/units/#{inspection.unit.id}" : "/"
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
require "sorbet-runtime"
-
4
require "csv"
-
-
4
class InspectionCsvExportService
-
4
extend T::Sig
-
-
4
sig do
-
2
params(
-
inspections: T.any(
-
ActiveRecord::Relation,
-
T::Array[Inspection]
-
)
-
).void
-
end
-
4
def initialize(inspections)
-
9
@inspections = inspections
-
end
-
-
6
sig { returns(String) }
-
4
def generate
-
8
CSV.generate(headers: true) do |csv|
-
8
csv << headers
-
-
8
@inspections.each do |inspection|
-
9
csv << row_data(inspection)
-
end
-
end
-
end
-
-
4
private
-
-
6
sig { returns(T::Array[String]) }
-
4
def headers
-
17
excluded_columns = %i[user_id inspector_company_id unit_id]
-
17
inspection_columns = Inspection.column_name_syms - excluded_columns
-
-
17
headers = inspection_columns
-
17
headers += %i[unit_name unit_serial unit_manufacturer unit_operator unit_description]
-
17
headers += %i[inspector_company_name]
-
17
headers += %i[inspector_user_email]
-
17
headers += %i[complete]
-
-
17
headers
-
end
-
-
6
sig { params(inspection: Inspection).returns(T::Array[T.untyped]) }
-
4
def row_data(inspection)
-
9
headers.map do |header|
-
243
in: 9
case header
-
9
in: 9
then: 8
else: 1
in :unit_name then inspection.unit&.name
-
9
in: 9
then: 8
else: 1
in :unit_serial then inspection.unit&.serial
-
9
in: 9
then: 8
else: 1
in :unit_manufacturer then inspection.unit&.manufacturer
-
9
in: 9
then: 8
else: 1
in :unit_operator then inspection.unit&.operator
-
9
in: 9
then: 8
else: 1
in :unit_description then inspection.unit&.description
-
9
in: 9
then: 9
else: 0
in :inspector_company_name then inspection.inspector_company&.name
-
9
in: 9
then: 9
else: 0
in :inspector_user_email then inspection.user&.email
-
9
else: 171
in :complete then inspection.complete?
-
171
then: 171
else: 0
else inspection.send(header) if inspection.respond_to?(header)
-
end
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
# Wrapper service for backward compatibility with tests
-
# Now delegates to Blueprinter serializers
-
4
class JsonSerializerService
-
4
def self.format_value(value)
-
case value
-
when: 0
when Date, Time, DateTime
-
value.strftime(JsonDateTransformer::API_DATE_FORMAT)
-
else: 0
else
-
value
-
end
-
end
-
-
4
def self.serialize_unit(unit, include_inspections: true)
-
8
else: 8
then: 0
return nil unless unit
-
-
8
then: 7
json_str = if include_inspections
-
7
UnitBlueprint.render_with_inspections(unit)
-
else: 1
else
-
1
UnitBlueprint.render(unit, view: :default)
-
end
-
8
JSON.parse(json_str, symbolize_names: true)
-
end
-
-
4
def self.serialize_inspection(inspection)
-
10
else: 10
then: 0
return nil unless inspection
-
-
10
JSON.parse(InspectionBlueprint.render(inspection), symbolize_names: true)
-
end
-
-
4
def self.serialize_assessment(assessment, klass)
-
excluded = PublicFieldFiltering::EXCLUDED_FIELDS
-
assessment_fields = klass.column_name_syms - excluded
-
-
data = {}
-
assessment_fields.each do |field|
-
value = assessment.send(field)
-
else: 0
then: 0
data[field.to_sym] = format_value(value) unless value.nil?
-
end
-
-
data
-
end
-
end
-
# typed: false
-
-
4
require "net/http"
-
-
4
class NtfyService
-
4
class << self
-
4
def notify(message, channel: :developer)
-
8
Thread.new do
-
8
send_notifications(message, channel)
-
rescue => e
-
Rails.logger.error("NtfyService error: #{e.message}")
-
ensure
-
8
ActiveRecord::Base.connection_pool.release_connection
-
end
-
end
-
-
4
private
-
-
4
def send_notifications(message, channel)
-
8
channels = determine_channels(channel)
-
14
channels.each { |ch| send_to_channel(message, ch) }
-
end
-
-
4
def determine_channels(channel)
-
8
case channel
-
when: 6
when :developer
-
6
[ENV["NTFY_CHANNEL_DEVELOPER"]].compact
-
when: 1
when :admin
-
1
[ENV["NTFY_CHANNEL_ADMIN"]].compact
-
when: 1
when :both
-
1
[ENV["NTFY_CHANNEL_DEVELOPER"], ENV["NTFY_CHANNEL_ADMIN"]].compact
-
else: 0
else
-
[]
-
end
-
end
-
-
4
def send_to_channel(message, channel_url)
-
6
then: 0
else: 6
return if channel_url.blank?
-
-
6
uri = URI.parse("https://ntfy.sh/#{channel_url}")
-
6
http = Net::HTTP.new(uri.host, uri.port)
-
6
http.use_ssl = true
-
-
6
request = Net::HTTP::Post.new(uri.path)
-
6
request["Title"] = "play-test notification"
-
6
request["Priority"] = "high"
-
6
request["Tags"] = "warning"
-
6
request.body = message
-
-
6
http.request(request)
-
end
-
end
-
end
-
# typed: strict
-
-
4
require "sorbet-runtime"
-
-
4
class PdfCacheService
-
4
extend T::Sig
-
-
4
CacheResult = Struct.new(:type, :data, keyword_init: true)
-
# type: :redirect or :pdf_data
-
# data: URL string for redirect, or PDF binary data
-
-
4
class << self
-
4
extend T::Sig
-
-
4
sig do
-
3
params(inspection: Inspection, options: T.untyped)
-
.returns(CacheResult)
-
end
-
4
def fetch_or_generate_inspection_pdf(inspection, **options)
-
# Never cache incomplete inspections
-
38
else: 6
then: 32
unless caching_enabled? && inspection.complete?
-
32
return generate_pdf_result(inspection, :inspection, **options)
-
end
-
-
6
fetch_or_generate(inspection, :inspection, **options)
-
end
-
-
7
sig { params(unit: Unit, options: T.untyped).returns(CacheResult) }
-
4
def fetch_or_generate_unit_pdf(unit, **options)
-
18
else: 2
then: 16
return generate_pdf_result(unit, :unit, **options) unless caching_enabled?
-
-
2
fetch_or_generate(unit, :unit, **options)
-
end
-
-
8
sig { params(inspection: Inspection).void }
-
4
def invalidate_inspection_cache(inspection)
-
68
invalidate_cache(inspection)
-
end
-
-
8
sig { params(unit: Unit).void }
-
4
def invalidate_unit_cache(unit)
-
1690
invalidate_cache(unit)
-
end
-
-
4
private
-
-
4
sig do
-
1
params(
-
record: T.any(Inspection, Unit),
-
type: Symbol,
-
options: T.untyped
-
).returns(CacheResult)
-
end
-
4
def fetch_or_generate(record, type, **options)
-
8
valid_cache = record.cached_pdf.attached? &&
-
cached_pdf_valid?(record.cached_pdf, record)
-
-
8
then: 2
if valid_cache
-
2
Rails.logger.info "PDF cache hit for #{type} #{record.id}"
-
-
2
then: 1
if redirect_to_s3?
-
1
url = generate_signed_url(record.cached_pdf)
-
1
CacheResult.new(type: :redirect, data: url)
-
else: 1
else
-
1
CacheResult.new(type: :stream, data: record.cached_pdf)
-
end
-
else: 6
else
-
6
Rails.logger.info "PDF cache miss for #{type} #{record.id}"
-
6
generate_and_cache(record, type, **options)
-
end
-
end
-
-
4
sig do
-
1
params(
-
record: T.any(Inspection, Unit),
-
type: Symbol,
-
options: T.untyped
-
).returns(CacheResult)
-
end
-
4
def generate_and_cache(record, type, **options)
-
6
result = generate_pdf_result(record, type, **options)
-
6
store_cached_pdf(record, result.data)
-
6
result
-
end
-
-
4
sig do
-
3
params(
-
record: T.any(Inspection, Unit),
-
type: Symbol,
-
options: T.untyped
-
).returns(CacheResult)
-
end
-
4
def generate_pdf_result(record, type, **options)
-
54
else: 0
pdf_document = case type
-
when: 36
when :inspection
-
36
PdfGeneratorService.generate_inspection_report(record, **options)
-
when: 18
when :unit
-
18
PdfGeneratorService.generate_unit_report(record, **options)
-
end
-
-
54
CacheResult.new(type: :pdf_data, data: pdf_document.render)
-
end
-
-
8
sig { params(record: T.any(Inspection, Unit)).void }
-
4
def invalidate_cache(record)
-
1758
else: 13
then: 1744
return unless caching_enabled?
-
-
13
then: 2
else: 11
record.cached_pdf.purge if record.cached_pdf.attached?
-
end
-
-
8
sig { returns(T::Boolean) }
-
4
def caching_enabled?
-
1814
pdf_cache_from_date.present?
-
end
-
-
5
sig { params(attachment: T.untyped).returns(String) }
-
4
def generate_signed_url(attachment)
-
# Generate a signed URL that expires in 1 hour
-
# The URL includes a timestamp in the signed parameters
-
1
attachment.blob.url(expires_in: 1.hour, disposition: "inline")
-
end
-
-
8
sig { returns(T.nilable(Date)) }
-
4
def pdf_cache_from_date
-
1786
@pdf_cache_from_date ||= begin
-
1786
date_string = ENV["PDF_CACHE_FROM"]
-
1786
then: 1785
else: 1
return nil if date_string.blank?
-
-
1
Date.parse(date_string)
-
rescue ArgumentError
-
1
error_msg = "Invalid PDF_CACHE_FROM date format: #{date_string}. "
-
1
error_msg += "Expected format: YYYY-MM-DD"
-
1
raise ArgumentError, error_msg
-
end
-
end
-
-
4
sig do
-
1
params(
-
attachment: T.untyped,
-
record: T.any(Inspection, Unit)
-
).returns(T::Boolean)
-
end
-
4
def cached_pdf_valid?(attachment, record)
-
6
then: 6
else: 0
else: 6
then: 0
return false unless attachment.blob&.created_at
-
-
6
cache_created_at = attachment.blob.created_at
-
6
cache_threshold = pdf_cache_from_date.beginning_of_day
-
-
# Check if cache is newer than the threshold date
-
6
else: 5
then: 1
return false unless cache_created_at > cache_threshold
-
-
# Check if user assets were updated after cache
-
5
!user_assets_updated_after?(record.user, cache_created_at)
-
end
-
-
4
sig do
-
1
params(
-
user: T.nilable(User),
-
cache_created_at: T.any(ActiveSupport::TimeWithZone, Date, Time)
-
).returns(T::Boolean)
-
end
-
4
def user_assets_updated_after?(user, cache_created_at)
-
5
else: 5
then: 0
return false unless user
-
-
5
then: 1
else: 4
if attachment_updated_after?(user.signature, cache_created_at)
-
1
Rails.logger.info "User signature updated after PDF cache"
-
1
return true
-
end
-
-
4
then: 2
else: 2
if attachment_updated_after?(user.logo, cache_created_at)
-
2
Rails.logger.info "User logo updated after PDF cache"
-
2
return true
-
end
-
-
2
false
-
end
-
-
4
sig do
-
1
params(
-
attachment: T.untyped,
-
reference_time: T.any(ActiveSupport::TimeWithZone, Date, Time)
-
).returns(T::Boolean)
-
end
-
4
def attachment_updated_after?(attachment, reference_time)
-
9
then: 9
else: 0
attachment&.attached? &&
-
attachment.blob.created_at > reference_time
-
end
-
-
4
sig do
-
1
params(
-
record: T.any(Inspection, Unit),
-
pdf_data: String
-
).void
-
end
-
4
def store_cached_pdf(record, pdf_data)
-
# Purge old cached PDF if exists
-
2
then: 1
else: 1
record.cached_pdf.purge if record.cached_pdf.attached?
-
-
# Store new cached PDF
-
2
type_name = record.class.name.downcase
-
2
filename = "#{type_name}_#{record.id}_cached_#{Time.current.to_i}.pdf"
-
-
# Create a StringIO with proper positioning
-
2
io = StringIO.new(pdf_data)
-
2
io.rewind
-
-
2
record.cached_pdf.attach(
-
io: io,
-
filename: filename,
-
content_type: "application/pdf"
-
)
-
end
-
-
5
sig { returns(T::Boolean) }
-
4
def redirect_to_s3?
-
2
ActiveModel::Type::Boolean.new.cast(ENV["REDIRECT_TO_S3_PDFS"])
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class PdfGeneratorService
-
4
include Configuration
-
-
4
def self.generate_inspection_report(inspection, debug_enabled: false, debug_queries: [])
-
41
require "prawn/table"
-
-
41
Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf|
-
41
Configuration.setup_pdf_fonts(pdf)
-
-
# Initialize array to collect all assessment blocks
-
41
assessment_blocks = []
-
-
# Header section
-
41
HeaderGenerator.generate_inspection_pdf_header(pdf, inspection)
-
-
# Unit details section
-
41
generate_inspection_unit_details(pdf, inspection)
-
-
# Risk assessment section (if present)
-
41
generate_risk_assessment_section(pdf, inspection)
-
-
# Generate all assessment sections in the correct UI order from applicable_tabs
-
41
generate_assessments_in_ui_order(inspection, assessment_blocks)
-
-
# Render footer and photo first to measure actual space used
-
41
cursor_before_footer = pdf.cursor
-
-
# Disclaimer footer (only on first page)
-
41
DisclaimerFooterRenderer.render_disclaimer_footer(pdf, inspection.user)
-
41
disclaimer_height = DisclaimerFooterRenderer.measure_footer_height(pdf)
-
-
# Add unit photo in bottom right corner
-
41
photo_height = ImageProcessor.measure_unit_photo_height(pdf, inspection.unit, 4)
-
41
then: 40
else: 1
then: 40
else: 1
ImageProcessor.add_unit_photo_footer(pdf, inspection.unit, 4) if inspection.unit&.photo
-
-
# Reset cursor to render assessments with proper space accounting
-
41
pdf.move_cursor_to(cursor_before_footer)
-
-
# Render all collected assessments in newspaper-style columns
-
41
render_assessment_blocks_in_columns(pdf, assessment_blocks, disclaimer_height, photo_height)
-
-
# Add DRAFT watermark overlay for draft inspections (except in test env)
-
41
then: 0
else: 41
Utilities.add_draft_watermark(pdf) if !inspection.complete? && !Rails.env.test?
-
-
# Add photos page if photos are attached
-
41
PhotosRenderer.generate_photos_page(pdf, inspection)
-
-
# Add debug info page if enabled (admins only)
-
41
then: 0
else: 41
DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present?
-
end
-
end
-
-
4
def self.generate_unit_report(unit, debug_enabled: false, debug_queries: [])
-
25
require "prawn/table"
-
-
# Preload all inspections once to avoid N+1 queries
-
25
completed_inspections = unit.inspections
-
.includes(:user, inspector_company: {logo_attachment: :blob})
-
.complete
-
.order(inspection_date: :desc)
-
-
25
last_inspection = completed_inspections.first
-
-
25
Prawn::Document.new(page_size: "A4", page_layout: :portrait) do |pdf|
-
25
Configuration.setup_pdf_fonts(pdf)
-
25
HeaderGenerator.generate_unit_pdf_header(pdf, unit)
-
25
generate_unit_details_with_inspection(pdf, unit, last_inspection)
-
25
generate_unit_inspection_history_with_data(pdf, unit, completed_inspections)
-
-
# Disclaimer footer (only on first page)
-
25
DisclaimerFooterRenderer.render_disclaimer_footer(pdf, unit.user)
-
-
# Add unit photo in bottom right corner (for unit PDFs, always use 3 columns)
-
25
then: 25
else: 0
ImageProcessor.add_unit_photo_footer(pdf, unit, 3) if unit.photo
-
-
# Add debug info page if enabled (admins only)
-
25
then: 0
else: 25
DebugInfoRenderer.add_debug_info_page(pdf, debug_queries) if debug_enabled && debug_queries.present?
-
end
-
end
-
-
4
def self.generate_inspection_unit_details(pdf, inspection)
-
41
unit = inspection.unit
-
-
41
else: 40
then: 1
return unless unit
-
-
40
unit_data = TableBuilder.build_unit_details_table_with_inspection(unit, inspection, :inspection)
-
40
TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.inspection.equipment_details"), unit_data)
-
-
# Hide the table entirely when no unit is associated
-
end
-
-
4
def self.generate_unit_details(pdf, unit)
-
unit_data = TableBuilder.build_unit_details_table(unit, :unit)
-
TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.unit.details"), unit_data)
-
end
-
-
4
def self.generate_unit_details_with_inspection(pdf, unit, _last_inspection)
-
25
unit_data = TableBuilder.build_unit_details_table(unit, :unit)
-
25
TableBuilder.create_unit_details_table(pdf, I18n.t("pdf.unit.details"), unit_data)
-
end
-
-
4
def self.generate_unit_inspection_history(pdf, unit)
-
# Check for completed inspections - preload associations to avoid N+1 queries
-
# Since all inspections belong to the same unit, we don't need to reload the unit
-
completed_inspections = unit.inspections
-
.includes(:user, inspector_company: {logo_attachment: :blob})
-
.complete
-
.order(inspection_date: :desc)
-
-
then: 0
if completed_inspections.empty?
-
TableBuilder.create_nice_box_table(pdf, I18n.t("pdf.unit.inspection_history"),
-
[[I18n.t("pdf.unit.no_completed_inspections"), ""]])
-
else: 0
else
-
TableBuilder.create_inspection_history_table(pdf, I18n.t("pdf.unit.inspection_history"), completed_inspections)
-
end
-
end
-
-
4
def self.generate_unit_inspection_history_with_data(pdf, _unit, completed_inspections)
-
25
then: 23
if completed_inspections.empty?
-
23
TableBuilder.create_nice_box_table(pdf, I18n.t("pdf.unit.inspection_history"),
-
[[I18n.t("pdf.unit.no_completed_inspections"), ""]])
-
else: 2
else
-
2
TableBuilder.create_inspection_history_table(pdf, I18n.t("pdf.unit.inspection_history"), completed_inspections)
-
end
-
end
-
-
4
def self.generate_risk_assessment_section(pdf, inspection)
-
41
then: 0
else: 41
return if inspection.risk_assessment.blank?
-
-
41
pdf.text I18n.t("pdf.inspection.risk_assessment"), size: HEADER_TEXT_SIZE, style: :bold
-
41
pdf.stroke_horizontal_rule
-
41
pdf.move_down 10
-
-
# Create a text box constrained to 4 lines with shrink_to_fit
-
41
line_height = 10 * 1.2 # Normal font size * line height multiplier
-
41
max_height = line_height * 4 # 4 lines max
-
-
41
pdf.text_box inspection.risk_assessment,
-
at: [0, pdf.cursor],
-
width: pdf.bounds.width,
-
height: max_height,
-
size: 10,
-
overflow: :shrink_to_fit,
-
min_font_size: 5
-
-
41
pdf.move_down max_height + 15
-
end
-
-
4
def self.generate_assessments_in_ui_order(inspection, assessment_blocks)
-
# Get the UI order from applicable_tabs (excluding non-assessment tabs)
-
41
ui_ordered_tabs = inspection.applicable_tabs - %w[inspection results]
-
-
41
ui_ordered_tabs.each do |tab_name|
-
271
assessment_key = :"#{tab_name}_assessment"
-
271
else: 271
then: 0
next unless inspection.assessment_applicable?(assessment_key)
-
-
271
assessment = inspection.send(assessment_key)
-
271
else: 271
then: 0
next unless assessment
-
-
# Build blocks for this assessment and add to the main array
-
271
blocks = AssessmentBlockBuilder.build_from_assessment(tab_name, assessment)
-
271
assessment_blocks.concat(blocks)
-
end
-
end
-
-
4
def self.render_assessment_blocks_in_columns(pdf, assessment_blocks, disclaimer_height, photo_height)
-
41
then: 0
else: 41
return if assessment_blocks.empty?
-
-
41
pdf.text I18n.t("pdf.inspection.assessments_section"), size: 12, style: :bold
-
41
pdf.stroke_horizontal_rule
-
41
pdf.move_down 15
-
-
# Calculate available height accounting for disclaimer footer only
-
41
then: 41
available_height = if pdf.page_number == 1
-
41
pdf.cursor - disclaimer_height
-
else: 0
else
-
pdf.cursor
-
end
-
-
# Check if we have enough space for at least some content
-
41
min_content_height = 100 # Minimum height for meaningful content
-
41
then: 0
else: 41
if available_height < min_content_height
-
pdf.start_new_page
-
available_height = pdf.cursor
-
0 # No footer on new pages
-
end
-
-
# Render assessments using the column layout with measured footer space
-
41
renderer = AssessmentColumns.new(assessment_blocks, available_height, photo_height)
-
41
renderer.render(pdf)
-
-
41
pdf.move_down 20
-
end
-
-
# Helper methods for backward compatibility and testing
-
4
def self.truncate_text(text, max_length)
-
3
Utilities.truncate_text(text, max_length)
-
end
-
-
4
def self.format_pass_fail(value)
-
3
Utilities.format_pass_fail(value)
-
end
-
-
4
def self.format_measurement(value, unit = "")
-
3
Utilities.format_measurement(value, unit)
-
end
-
end
-
4
class PdfGeneratorService
-
4
class AssessmentBlock
-
4
attr_reader :type, :pass_fail, :name, :value, :comment
-
-
4
def initialize(type:, pass_fail: nil, name: nil, value: nil, comment: nil)
-
3745
@type = type
-
3745
@pass_fail = pass_fail
-
3745
@name = name
-
3745
@value = value
-
3745
@comment = comment
-
end
-
-
4
def header?
-
21803
type == :header
-
end
-
-
4
def value?
-
107
type == :value
-
end
-
-
4
def comment?
-
92
type == :comment
-
end
-
end
-
end
-
# typed: false
-
-
4
class PdfGeneratorService
-
4
class AssessmentBlockBuilder
-
4
include Configuration
-
-
4
def self.build_from_assessment(assessment_type, assessment)
-
291
new(assessment_type, assessment).build
-
end
-
-
4
def initialize(assessment_type, assessment)
-
313
@assessment_type = assessment_type
-
313
@assessment = assessment
-
313
@not_applicable_fields = get_not_applicable_fields
-
end
-
-
4
def build
-
295
blocks = []
-
-
# Add header block
-
295
blocks << AssessmentBlock.new(
-
type: :header,
-
name: I18n.t("forms.#{@assessment_type}.header")
-
)
-
-
# Process fields
-
295
ordered_fields = get_form_config_fields
-
295
field_groups = group_assessment_fields(ordered_fields)
-
-
295
field_groups.each do |base, fields|
-
# Skip if this is a not-applicable field with value 0
-
2511
main_field = fields[:base] || fields[:pass]
-
2511
then: 3
else: 2508
if main_field && field_is_not_applicable?(main_field)
-
3
next
-
end
-
-
# Add value block
-
2508
then: 2465
if main_field
-
2465
value = @assessment.send(main_field)
-
2465
label = get_field_label(fields)
-
2465
pass_value = determine_pass_value(fields, main_field, value)
-
2465
is_pass_field = main_field.to_s.end_with?("_pass")
-
-
# For boolean fields that aren't pass/fail fields
-
2465
is_bool_non_pass = [true, false].include?(value) &&
-
!is_pass_field && pass_value.nil?
-
2465
then: 24
blocks << if is_bool_non_pass
-
24
AssessmentBlock.new(
-
type: :value,
-
name: label,
-
24
then: 0
else: 24
value: value ? I18n.t("shared.yes") : I18n.t("shared.no")
-
)
-
else: 2441
else
-
2441
AssessmentBlock.new(
-
type: :value,
-
pass_fail: pass_value,
-
name: label,
-
2441
then: 1280
else: 1161
value: is_pass_field ? nil : value
-
)
-
else: 43
end
-
43
else: 0
elsif fields[:comment]
-
then: 43
# Handle standalone comment fields (no base or pass field)
-
43
label = get_field_label(fields)
-
43
comment = @assessment.send(fields[:comment])
-
43
else: 19
if comment.present?
-
then: 24
# Add a label block for the standalone comment
-
24
blocks << AssessmentBlock.new(
-
type: :value,
-
name: label,
-
value: nil
-
)
-
end
-
end
-
-
# Add comment block if present
-
2508
then: 2213
else: 295
if fields[:comment]
-
2213
comment = @assessment.send(fields[:comment])
-
2213
then: 954
else: 1259
if comment.present?
-
954
blocks << AssessmentBlock.new(
-
type: :comment,
-
comment: comment
-
)
-
end
-
end
-
end
-
-
295
blocks
-
end
-
-
4
private
-
-
4
def get_form_config_fields
-
298
else: 298
then: 0
return [] unless @assessment.class.respond_to?(:form_fields)
-
-
298
form_config = @assessment.class.form_fields
-
298
ordered_fields = []
-
-
298
form_config.each do |section|
-
609
section[:fields].each do |field_config|
-
2554
field_name = field_config[:field]
-
2554
partial_name = field_config[:partial]
-
-
# Get composite fields first to check if any exist
-
2554
composite_fields = ChobbleForms::FieldUtils.get_composite_fields(field_name, partial_name)
-
-
# Skip if neither the base field nor any composite fields exist
-
2554
has_base = @assessment.respond_to?(field_name)
-
4755
has_composites = composite_fields.any? { |cf| @assessment.respond_to?(cf) }
-
2554
else: 2554
then: 0
next unless has_base || has_composites
-
-
# Add base field if it exists
-
2554
then: 1263
else: 1291
ordered_fields << field_name if has_base
-
-
# Add composite fields that exist
-
2554
composite_fields.each do |composite_field|
-
3917
then: 3917
else: 0
ordered_fields << composite_field if @assessment.respond_to?(composite_field)
-
end
-
end
-
end
-
-
298
ordered_fields
-
end
-
-
4
def group_assessment_fields(field_keys)
-
299
field_keys.each_with_object({}) do |field, groups|
-
5123
field_str = field.to_s
-
5123
else: 5120
then: 3
next unless @assessment.respond_to?(field_str)
-
-
5120
base_field = ChobbleForms::FieldUtils.strip_field_suffix(field)
-
5120
groups[base_field] ||= {}
-
-
5120
when: 1708
field_type = case field_str
-
1708
when: 2221
when /pass$/ then :pass
-
2221
else: 1191
when /comment$/ then :comment
-
1191
else :base
-
end
-
-
5120
groups[base_field][field_type] = field
-
end
-
end
-
-
4
def get_field_label(fields)
-
# Try in order: base field, pass field, comment field
-
2512
then: 1186
if fields[:base]
-
1186
else: 1326
field_label(fields[:base])
-
1326
elsif fields[:pass]
-
then: 1281
# For pass fields, use the base field name for the label
-
1281
base_name = ChobbleForms::FieldUtils.strip_field_suffix(fields[:pass])
-
1281
else: 45
field_label(base_name)
-
45
elsif fields[:comment]
-
then: 44
# For standalone comment fields, use the base field name
-
44
base_name = ChobbleForms::FieldUtils.strip_field_suffix(fields[:comment])
-
44
field_label(base_name)
-
else: 1
else
-
1
raise "No valid fields found: #{fields}"
-
end
-
end
-
-
4
def field_label(field_name)
-
2511
I18n.t!("forms.#{@assessment_type}.fields.#{field_name}")
-
end
-
-
4
def determine_pass_value(fields, main_field, value)
-
2468
then: 1704
else: 764
return @assessment.send(fields[:pass]) if fields[:pass]
-
764
then: 0
else: 764
return value if main_field.to_s.end_with?("_pass")
-
764
nil
-
end
-
-
4
def get_not_applicable_fields
-
314
else: 314
then: 0
return [] unless @assessment.class.respond_to?(:form_fields)
-
-
314
@assessment.class.form_fields
-
640
.flat_map { |section| section[:fields] }
-
2727
then: 1135
else: 1592
.select { |field| field[:attributes]&.dig(:add_not_applicable) }
-
53
.map { |field| field[:field].to_sym }
-
end
-
-
4
def field_is_not_applicable?(field)
-
2471
else: 48
then: 2423
return false unless @not_applicable_fields.include?(field)
-
-
48
value = @assessment.send(field)
-
# Field is not applicable if it has add_not_applicable and value is 0
-
48
value.present? && value.to_i == 0
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class AssessmentBlockRenderer
-
4
include Configuration
-
-
4
ASSESSMENT_MARGIN_AFTER_TITLE = 3
-
4
ASSESSMENT_TITLE_SIZE = 9
-
4
ASSESSMENT_FIELD_TEXT_SIZE = 7
-
-
# Calculate column width (1/4 of page width minus spacing)
-
4
PAGE_WIDTH = 595.28 - (2 * 36) # A4 width minus margins
-
4
TOTAL_SPACER_WIDTH = Configuration::ASSESSMENT_COLUMN_SPACER * 3
-
4
COLUMN_WIDTH = (PAGE_WIDTH - TOTAL_SPACER_WIDTH) / 4.0
-
-
4
def initialize(font_size: ASSESSMENT_FIELD_TEXT_SIZE)
-
198
@font_size = font_size
-
end
-
-
4
def render_fragments(block)
-
21772
case block.type
-
when: 1564
when :header
-
1564
render_header_fragments(block)
-
when: 13066
when :value
-
13066
render_value_fragments(block)
-
when: 7142
when :comment
-
7142
render_comment_fragments(block)
-
else: 0
else
-
raise ArgumentError, "Unknown block type: #{block.type}"
-
end
-
end
-
-
4
def font_size_for(block)
-
21772
then: 1564
else: 20208
block.header? ? ASSESSMENT_TITLE_SIZE : @font_size
-
end
-
-
4
def height_for(block, pdf)
-
18313
fragments = render_fragments(block)
-
18313
then: 0
else: 18313
return 0 if fragments.empty?
-
-
18313
font_size = font_size_for(block)
-
-
# Convert fragments to formatted text array
-
18313
formatted_text = fragments.map do |fragment|
-
28240
styles = []
-
28240
then: 17804
else: 10436
styles << :bold if fragment[:bold]
-
28240
then: 6205
else: 22035
styles << :italic if fragment[:italic]
-
-
{
-
28240
text: fragment[:text],
-
styles: styles,
-
color: fragment[:color]
-
}
-
end
-
-
# Use height_of_formatted to get the actual height with wrapping
-
18313
base_height = pdf.height_of_formatted(
-
formatted_text,
-
width: COLUMN_WIDTH,
-
size: font_size
-
)
-
-
# Add 33% of font size as spacing
-
18313
spacing = (font_size * 0.33).round(1)
-
18313
base_height + spacing
-
end
-
-
4
private
-
-
4
def render_header_fragments(block)
-
1564
text = block.name || block.value
-
1564
[{text: text, bold: true, color: "000000"}]
-
end
-
-
4
def render_value_fragments(block)
-
13066
fragments = []
-
-
# Add pass/fail indicator if present
-
13066
then: 6577
else: 6489
if !block.pass_fail.nil?
-
6577
when: 6577
indicator, color = case block.pass_fail
-
6577
when: 0
when true, "pass" then [I18n.t("shared.pass_pdf"), Configuration::PASS_COLOR]
-
else: 0
when false, "fail" then [I18n.t("shared.fail_pdf"), Configuration::FAIL_COLOR]
-
else [I18n.t("shared.na_pdf"), Configuration::NA_COLOR]
-
end
-
6577
fragments << {text: "#{indicator} ", bold: true, color: color}
-
end
-
-
# Add field name
-
13066
then: 13066
else: 0
if block.name
-
13066
fragments << {text: block.name, bold: true, color: "000000"}
-
end
-
-
# Add value if present and not a pass/fail field
-
13066
then: 4887
else: 8179
if block.value && !block.name.to_s.end_with?("_pass")
-
4887
fragments << {text: ": #{block.value}", bold: false, color: "000000"}
-
end
-
-
13066
fragments
-
end
-
-
4
def render_comment_fragments(block)
-
7142
then: 0
else: 7142
return [] if block.comment.blank?
-
-
7142
[{text: block.comment, bold: false, color: Configuration::HEADER_COLOR, italic: true}]
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class AssessmentColumns
-
# Include configuration for column spacing and font sizes
-
4
include Configuration
-
-
4
attr_reader :assessment_blocks, :assessment_results_height, :photo_height
-
-
4
def initialize(assessment_blocks, assessment_results_height, photo_height)
-
41
@assessment_blocks = assessment_blocks
-
41
@assessment_results_height = assessment_results_height
-
41
@photo_height = photo_height
-
end
-
-
4
def render(pdf)
-
# Try progressively smaller font sizes
-
41
font_size = Configuration::ASSESSMENT_FIELD_TEXT_SIZE_PREFERRED
-
41
min_font_size = Configuration::MIN_ASSESSMENT_FONT_SIZE
-
-
41
body: 154
while font_size >= min_font_size
-
154
then: 41
else: 113
if content_fits_with_font_size?(pdf, font_size)
-
41
render_with_font_size(pdf, font_size)
-
41
return true
-
end
-
113
font_size -= 1
-
end
-
-
# If we still can't fit, render with minimum font size anyway
-
render_with_font_size(pdf, min_font_size)
-
false
-
end
-
-
4
private
-
-
4
def content_fits_with_font_size?(pdf, font_size)
-
# Calculate total content height
-
154
total_height = calculate_total_content_height(font_size, pdf)
-
-
# Calculate column capacity
-
154
columns = calculate_column_boxes(pdf)
-
770
total_capacity = columns.sum { |col| col[:height] }
-
-
154
total_height <= total_capacity
-
end
-
-
4
def calculate_total_content_height(font_size, pdf)
-
154
renderer = AssessmentBlockRenderer.new(font_size: font_size)
-
154
total_height = 0
-
-
154
@assessment_blocks.each do |block|
-
# Add height for this block using actual PDF document
-
14847
total_height += renderer.height_for(block, pdf)
-
end
-
-
154
total_height
-
end
-
-
4
def render_with_font_size(pdf, font_size)
-
# Calculate column dimensions
-
41
columns = calculate_column_boxes(pdf)
-
-
# Save the starting position
-
41
start_y = pdf.cursor
-
-
# Track content placement across columns
-
41
content_blocks = prepare_content_blocks(pdf, font_size)
-
41
place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
-
-
# Move cursor to end of assessment area
-
41
pdf.move_cursor_to(start_y - assessment_results_height)
-
end
-
-
4
def prepare_content_blocks(pdf, font_size)
-
41
blocks = []
-
41
renderer = AssessmentBlockRenderer.new(font_size: font_size)
-
-
41
@assessment_blocks.each do |block|
-
# Get rendered fragments and height for this block
-
3459
fragments = renderer.render_fragments(block)
-
3459
height = renderer.height_for(block, pdf)
-
-
3459
blocks << {
-
type: block.type,
-
fragments: fragments,
-
height: height,
-
font_size: renderer.font_size_for(block)
-
}
-
end
-
-
41
blocks
-
end
-
-
4
def place_content_in_columns(pdf, content_blocks, columns, start_y, font_size)
-
41
current_column = 0
-
41
column_y = start_y
-
-
41
content_blocks.each do |content|
-
# Check if we need to move to next column
-
3399
then: 3399
if current_column < columns.size
-
3399
available = column_y - (start_y - columns[current_column][:height])
-
-
3399
else: 3277
if available < content[:height]
-
then: 122
# Move to next column
-
122
current_column += 1
-
122
column_y = start_y
-
-
# Stop if we run out of columns
-
122
then: 3
else: 119
break if current_column >= columns.size
-
end
-
else: 0
else
-
break
-
end
-
-
# Render content in current column
-
3396
column = columns[current_column]
-
3396
render_content_at_position(pdf, content, column, column_y, font_size)
-
-
# Update position
-
3396
column_y -= content[:height]
-
end
-
end
-
-
4
def render_content_at_position(pdf, content, column, y_pos, font_size)
-
# Save original state
-
3396
original_y = pdf.cursor
-
3396
original_fill_color = pdf.fill_color
-
-
# Calculate actual x position
-
3396
actual_x = column[:x]
-
-
# Use the font size from the content block if available
-
3396
text_size = content[:font_size] || font_size
-
-
# Convert fragments to formatted text array for proper wrapping
-
3396
formatted_text = content[:fragments].map do |fragment|
-
4894
styles = []
-
4894
then: 3340
else: 1554
styles << :bold if fragment[:bold]
-
4894
then: 913
else: 3981
styles << :italic if fragment[:italic]
-
-
{
-
4894
text: fragment[:text],
-
styles: styles,
-
color: fragment[:color]
-
}
-
end
-
-
# Render as single formatted text box for proper wrapping
-
3396
pdf.formatted_text_box(
-
formatted_text,
-
at: [actual_x, y_pos],
-
width: column[:width],
-
size: text_size,
-
overflow: :truncate
-
)
-
-
# Restore original state
-
3396
pdf.fill_color original_fill_color
-
3396
pdf.move_cursor_to original_y
-
end
-
-
4
def calculate_column_boxes(pdf)
-
195
total_spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER * 3
-
195
column_width = (pdf.bounds.width - total_spacer_width) / 4.0
-
-
195
columns = []
-
-
# First three columns - full height
-
195
3.times do |i|
-
585
x = i * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER)
-
585
columns << {
-
x: x,
-
y: pdf.cursor,
-
width: column_width,
-
height: assessment_results_height
-
}
-
end
-
-
# Fourth column - reduced by photo height
-
195
fourth_column_height = [assessment_results_height - photo_height - 5, 0].max # 5pt buffer
-
195
columns << {
-
195
x: 3 * (column_width + Configuration::ASSESSMENT_COLUMN_SPACER),
-
y: pdf.cursor,
-
width: column_width,
-
height: fourth_column_height
-
}
-
-
195
columns
-
end
-
-
4
def calculate_line_height(font_size)
-
font_size * 1.2
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class PdfGeneratorService
-
4
module Configuration
-
# Unit table constants
-
4
UNIT_NAME_MAX_LENGTH = 30
-
4
UNIT_TABLE_CELL_PADDING = [6, 4].freeze
-
4
UNIT_TABLE_TEXT_SIZE = 9
-
-
# General text and spacing constants
-
4
HEADER_TEXT_SIZE = 12
-
4
HEADER_SPACING = 8
-
4
STATUS_TEXT_SIZE = 14
-
4
STATUS_SPACING = 15
-
4
SECTION_TITLE_SIZE = 14
-
4
COMMENTS_PADDING = 20
-
-
# Header table constants
-
4
LOGO_HEIGHT = 50
-
4
HEADER_TABLE_PADDING = [5, 0].freeze
-
4
LOGO_COLUMN_WIDTH_RATIO = 1.0 / 3.0
-
-
# Table constants
-
4
TABLE_CELL_PADDING = [5, 10].freeze
-
4
TABLE_FIRST_COLUMN_WIDTH = 150
-
4
NICE_TABLE_CELL_PADDING = [4, 8].freeze
-
4
NICE_TABLE_TEXT_SIZE = 10
-
-
# Inspection history table styling
-
4
HISTORY_TABLE_TEXT_SIZE = 8
-
4
HISTORY_TABLE_HEADER_COLOR = "F5F5F5"
-
4
HISTORY_TABLE_ROW_COLOR = "FAFAFA"
-
4
HISTORY_TABLE_ALT_ROW_COLOR = "F0F0F0"
-
4
PASS_COLOR = "008000" # Green
-
4
FAIL_COLOR = "CC0000" # Red
-
4
NA_COLOR = "4169E1" # Royal Blue
-
4
HEADER_COLOR = "663399" # Purple
-
4
SUBTITLE_COLOR = "666666" # Gray
-
-
# Inspection history table column widths
-
4
HISTORY_DATE_COLUMN_WIDTH = 90 # Date column (DD/MM/YYYY) - slightly wider
-
4
HISTORY_RESULT_COLUMN_WIDTH = 45 # Result column (PASS/FAIL) - narrower
-
4
HISTORY_INSPECTOR_WIDTH_PERCENT = 0.5 # 50% of remaining space
-
4
HISTORY_LOCATION_WIDTH_PERCENT = 0.5 # 50% of remaining space
-
-
# Assessment layout constants
-
4
ASSESSMENT_COLUMNS_COUNT = 4
-
4
ASSESSMENT_COLUMN_SPACER = 10
-
4
ASSESSMENT_TITLE_SIZE = 9
-
4
ASSESSMENT_FIELD_TEXT_SIZE = 7
-
4
ASSESSMENT_FIELD_TEXT_SIZE_PREFERRED = 10
-
4
MIN_ASSESSMENT_FONT_SIZE = 3
-
4
ASSESSMENT_BLOCK_SPACING = 8
-
-
# QR code size is 3 lines of header text (12pt * 1.5 line height * 3 lines)
-
4
QR_CODE_SIZE = (HEADER_TEXT_SIZE * 1.5 * 3).round
-
4
QR_CODE_MARGIN = 0 # No margin - align with page edge
-
4
QR_CODE_BOTTOM_OFFSET = HEADER_SPACING # Match header spacing from top
-
-
# Unit photo constants
-
4
UNIT_PHOTO_X_OFFSET = 130
-
4
UNIT_PHOTO_WIDTH = 120
-
4
UNIT_PHOTO_HEIGHT = 90
-
-
# Watermark constants
-
4
WATERMARK_TRANSPARENCY = 0.4
-
4
WATERMARK_TEXT_SIZE = 24
-
4
WATERMARK_WIDTH = 100
-
4
WATERMARK_HEIGHT = 60
-
-
# Photos page constants
-
4
PHOTO_MAX_HEIGHT_PERCENT = 0.25 # 25% of page height
-
4
PHOTO_SPACING = 20 # Space between photos
-
4
PHOTO_LABEL_SIZE = 10 # Size of "Photo 1", "Photo 2", "Photo 3" text
-
4
PHOTO_LABEL_SPACING = 5 # Space between photo and label
-
-
# Disclaimer footer constants
-
4
DISCLAIMER_HEADER_SIZE = HEADER_TEXT_SIZE # Match existing header style
-
4
DISCLAIMER_TEXT_SIZE = 10
-
4
DISCLAIMER_TEXT_LINES = 4 # Height in lines of text for disclaimer
-
4
TEXT_LINE_HEIGHT = DISCLAIMER_TEXT_SIZE * 1.5 # Standard line height multiplier
-
4
DISCLAIMER_TEXT_HEIGHT = DISCLAIMER_TEXT_LINES * TEXT_LINE_HEIGHT # Total disclaimer text height
-
4
FOOTER_INTERNAL_PADDING = 10 # Padding between elements within footer
-
4
FOOTER_VERTICAL_PADDING = 15 # Bottom padding for footer
-
4
FOOTER_TOP_PADDING = 30 # Top padding for footer (about 2 lines)
-
FOOTER_HEIGHT =
-
4
FOOTER_TOP_PADDING +
-
DISCLAIMER_HEADER_SIZE +
-
FOOTER_INTERNAL_PADDING +
-
DISCLAIMER_TEXT_HEIGHT +
-
FOOTER_VERTICAL_PADDING # Total footer height
-
4
DISCLAIMER_TEXT_WIDTH_PERCENT = 0.75 # Disclaimer text takes 75% of width
-
-
4
def self.setup_pdf_fonts(pdf)
-
66
font_path = Rails.root.join("app/assets/fonts")
-
66
pdf.font_families.update(
-
"NotoSans" => {
-
normal: "#{font_path}/NotoSans-Regular.ttf",
-
bold: "#{font_path}/NotoSans-Bold.ttf",
-
italic: "#{font_path}/NotoSans-Regular.ttf",
-
bold_italic: "#{font_path}/NotoSans-Bold.ttf"
-
},
-
"NotoEmoji" => {
-
normal: "#{font_path}/NotoEmoji-Regular.ttf"
-
}
-
)
-
66
pdf.font "NotoSans"
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class DebugInfoRenderer
-
4
include Configuration
-
-
4
def self.add_debug_info_page(pdf, queries)
-
then: 0
else: 0
return if queries.blank?
-
-
# Start a new page for debug info
-
pdf.start_new_page
-
-
# Header
-
pdf.text I18n.t("debug.title"), size: HEADER_TEXT_SIZE, style: :bold
-
pdf.stroke_horizontal_rule
-
pdf.move_down 10
-
-
# Summary info
-
pdf.text "#{I18n.t("debug.query_count")}: #{queries.size}", size: NICE_TABLE_TEXT_SIZE
-
pdf.move_down 10
-
-
# Build table data
-
table_data = [
-
[I18n.t("debug.query"), I18n.t("debug.duration"), I18n.t("debug.name")]
-
]
-
-
queries.each do |query|
-
table_data << [
-
query[:sql],
-
"#{query[:duration]} ms",
-
query[:name] || ""
-
]
-
end
-
-
# Create the table
-
pdf.table(table_data, width: pdf.bounds.width) do |t|
-
# Header row styling
-
t.row(0).background_color = "333333"
-
t.row(0).text_color = "FFFFFF"
-
t.row(0).font_style = :bold
-
-
# General styling
-
t.cells.borders = [:bottom]
-
t.cells.border_color = "DDDDDD"
-
t.cells.padding = TABLE_CELL_PADDING
-
t.cells.size = 8
-
-
# Column widths
-
t.columns(0).width = pdf.bounds.width * 0.6 # Query column gets most space
-
t.columns(1).width = pdf.bounds.width * 0.2 # Duration
-
t.columns(2).width = pdf.bounds.width * 0.2 # Name
-
-
# Alternating row colors
-
(1..table_data.length - 1).each do |i|
-
then: 0
else: 0
t.row(i).background_color = i.odd? ? "FFFFFF" : "F5F5F5"
-
end
-
end
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class PdfGeneratorService
-
4
class DisclaimerFooterRenderer
-
4
include Configuration
-
-
4
def self.render_disclaimer_footer(pdf, user)
-
70
else: 69
then: 1
return unless should_render_footer?(pdf)
-
-
# Save current position
-
69
original_y = pdf.cursor
-
-
# Move to footer position
-
69
footer_y = FOOTER_HEIGHT
-
69
pdf.move_cursor_to footer_y
-
-
# Create bounding box for footer
-
69
bounding_box_width = pdf.bounds.width
-
69
bounding_box_at = [0, pdf.cursor]
-
69
pdf.bounding_box(bounding_box_at,
-
width: bounding_box_width,
-
height: FOOTER_HEIGHT) do
-
# Add top padding
-
69
pdf.move_down FOOTER_TOP_PADDING
-
69
render_footer_content(pdf, user)
-
end
-
-
# Restore position
-
69
pdf.move_cursor_to original_y
-
end
-
-
4
def self.measure_footer_height(pdf)
-
45
else: 43
then: 2
return 0 unless should_render_footer?(pdf)
-
-
43
FOOTER_HEIGHT
-
end
-
-
4
def self.should_render_footer?(pdf)
-
# Only render on first page
-
111
pdf.page_number == 1
-
end
-
-
4
def self.render_footer_content(pdf, user)
-
# Render disclaimer header
-
72
render_disclaimer_header(pdf)
-
-
72
pdf.move_down FOOTER_INTERNAL_PADDING
-
-
# Check what content we have
-
72
then: 72
else: 0
then: 72
else: 0
has_signature = user&.signature&.attached?
-
72
then: 2
else: 0
then: 2
else: 0
has_user_logo = ENV["PDF_LOGO"].present? && user&.logo&.attached?
-
72
pdf.bounds.width
-
-
first_row = [
-
72
pdf.make_cell(
-
content: I18n.t("pdf.disclaimer.text"),
-
size: DISCLAIMER_TEXT_SIZE,
-
inline_format: true,
-
valign: :top,
-
72
then: 3
else: 69
padding: [0, (has_signature || has_user_logo) ? 10 : 0, 0, 0]
-
)
-
]
-
-
72
then: 2
else: 70
if has_signature
-
2
first_row << pdf.make_cell(
-
image: StringIO.new(user.signature.download),
-
fit: [100, DISCLAIMER_TEXT_HEIGHT],
-
width: 100,
-
borders: %i[top bottom left right],
-
border_color: "CCCCCC",
-
border_width: 1,
-
padding: 5,
-
2
then: 1
else: 1
padding_right: has_user_logo ? 10 : 5,
-
padding_left: 5
-
)
-
end
-
-
72
then: 2
else: 70
if has_user_logo
-
2
first_row << pdf.make_cell(
-
image: StringIO.new(user.logo.download),
-
fit: [1000, DISCLAIMER_TEXT_HEIGHT],
-
borders: [],
-
padding: [0, 0, 0, 10]
-
)
-
end
-
-
72
then: 2
else: 70
if has_signature
-
2
caption_row = [pdf.make_cell(content: "", borders: [], padding: 0)]
-
2
caption_row << pdf.make_cell(
-
content: I18n.t("pdf.signature.caption"),
-
size: DISCLAIMER_TEXT_SIZE,
-
align: :center,
-
borders: [],
-
2
then: 1
else: 1
padding: [5, has_user_logo ? 10 : 5, 0, 5]
-
)
-
2
then: 1
else: 1
caption_row << pdf.make_cell(content: "", borders: [], padding: 0) if has_user_logo
-
end
-
-
72
first_row.length
-
-
72
table_data = [first_row]
-
# table_data << caption_row if has_signature
-
-
72
pdf.table(table_data) do |t|
-
66
t.cells.borders = []
-
end
-
end
-
-
4
def self.render_disclaimer_header(pdf)
-
68
pdf.text I18n.t("pdf.disclaimer.header"),
-
size: DISCLAIMER_HEADER_SIZE,
-
style: :bold
-
68
pdf.stroke_horizontal_rule
-
end
-
end
-
end
-
# typed: false
-
-
4
class PdfGeneratorService
-
4
class HeaderGenerator
-
4
include Configuration
-
-
4
def self.generate_inspection_pdf_header(pdf, inspection)
-
41
create_inspection_header(pdf, inspection)
-
# Generate QR code in top left corner
-
41
ImageProcessor.generate_qr_code_header(pdf, inspection)
-
end
-
-
4
def self.create_inspection_header(pdf, inspection)
-
41
inspector_user = inspection.user
-
41
report_id_text = build_report_id_text(inspection)
-
41
status_text, status_color = build_status_text_and_color(inspection)
-
-
41
render_header_with_logo(pdf, inspector_user) do |logo_width|
-
41
render_inspection_text_section(pdf, inspection, report_id_text,
-
status_text, status_color, logo_width)
-
end
-
-
41
pdf.move_down Configuration::STATUS_SPACING
-
end
-
-
4
def self.generate_unit_pdf_header(pdf, unit)
-
25
create_unit_header(pdf, unit)
-
# Generate QR code in top left corner
-
25
ImageProcessor.generate_qr_code_header(pdf, unit)
-
end
-
-
4
def self.create_unit_header(pdf, unit)
-
25
user = unit.user
-
25
unit_id_text = build_unit_id_text(unit)
-
-
25
render_header_with_logo(pdf, user) do |logo_width|
-
25
render_unit_text_section(pdf, unit, unit_id_text, logo_width)
-
end
-
-
25
pdf.move_down Configuration::STATUS_SPACING
-
end
-
-
4
class << self
-
4
private
-
-
4
def build_report_id_text(inspection)
-
41
"#{I18n.t("pdf.inspection.fields.report_id")}: #{inspection.id}"
-
end
-
-
4
def build_status_text_and_color(inspection)
-
41
else: 0
case inspection.passed
-
when: 37
when true
-
37
[I18n.t("pdf.inspection.passed"), Configuration::PASS_COLOR]
-
when: 4
when false
-
4
[I18n.t("pdf.inspection.failed"), Configuration::FAIL_COLOR]
-
when: 0
when nil
-
[I18n.t("pdf.inspection.in_progress"), Configuration::NA_COLOR]
-
end
-
end
-
-
4
def build_unit_id_text(unit)
-
25
"#{I18n.t("pdf.unit.fields.unit_id")}: #{unit.id}"
-
end
-
-
4
def render_header_with_logo(pdf, user)
-
66
logo_width, logo_data, logo_attachment = prepare_logo(user)
-
-
66
pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width) do
-
66
yield(logo_width)
-
66
then: 0
else: 66
if logo_data
-
render_logo_section(pdf, logo_data, logo_width, logo_attachment)
-
end
-
end
-
end
-
-
4
def prepare_logo(user)
-
# Check if PDF_LOGO env variable is set to override user logo
-
66
then: 0
else: 66
if ENV["PDF_LOGO"].present?
-
logo_filename = ENV["PDF_LOGO"]
-
logo_path = Rails.root.join("app", "assets", "images", logo_filename)
-
logo_data = File.read(logo_path, mode: "rb")
-
logo_height = Configuration::LOGO_HEIGHT
-
logo_width = logo_height * 2 + 10
-
return [logo_width, logo_data, nil]
-
end
-
-
66
then: 66
else: 0
then: 66
else: 0
else: 0
then: 66
return [0, nil, nil] unless user&.logo&.attached?
-
-
logo_data = user.logo.download
-
logo_height = Configuration::LOGO_HEIGHT
-
logo_width = logo_height * 2 + 10
-
-
[logo_width, logo_data, user.logo]
-
end
-
-
4
def render_inspection_text_section(pdf, inspection, report_id_text,
-
status_text, status_color, logo_width)
-
# Shift text to the right to accommodate QR code
-
41
qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING
-
41
width = pdf.bounds.width - logo_width - qr_offset
-
41
pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do
-
41
pdf.text report_id_text, size: Configuration::HEADER_TEXT_SIZE,
-
style: :bold
-
41
pdf.text status_text, size: Configuration::HEADER_TEXT_SIZE,
-
style: :bold,
-
color: status_color
-
-
41
expiry_label = I18n.t("pdf.inspection.fields.expiry_date")
-
41
expiry_value = Utilities.format_date(inspection.reinspection_date)
-
41
pdf.text "#{expiry_label}: #{expiry_value}",
-
size: Configuration::HEADER_TEXT_SIZE, style: :bold
-
end
-
end
-
-
4
def render_unit_text_section(pdf, unit, unit_id_text, logo_width)
-
# Shift text to the right to accommodate QR code
-
25
qr_offset = Configuration::QR_CODE_SIZE + Configuration::HEADER_SPACING
-
25
width = pdf.bounds.width - logo_width - qr_offset
-
25
pdf.bounding_box([qr_offset, pdf.bounds.top], width: width) do
-
25
pdf.text unit_id_text, size: Configuration::HEADER_TEXT_SIZE,
-
style: :bold
-
-
25
expiry_label = I18n.t("pdf.unit.fields.expiry_date")
-
25
then: 2
else: 23
then: 2
expiry_value = if unit.last_inspection&.reinspection_date
-
2
Utilities.format_date(unit.last_inspection.reinspection_date)
-
else: 23
else
-
23
I18n.t("pdf.unit.fields.na")
-
end
-
25
pdf.text "#{expiry_label}: #{expiry_value}",
-
size: Configuration::HEADER_TEXT_SIZE, style: :bold
-
-
# Add extra line of spacing to match 3-line QR code height
-
25
pdf.move_down Configuration::HEADER_TEXT_SIZE * 1.5
-
end
-
end
-
-
4
def render_logo_section(pdf, logo_data, logo_width, logo_attachment)
-
x_position = pdf.bounds.width - logo_width + 10
-
pdf.bounding_box([x_position, pdf.bounds.top],
-
width: logo_width - 10) do
-
pdf.image StringIO.new(logo_data), height: Configuration::LOGO_HEIGHT,
-
position: :right
-
end
-
rescue Prawn::Errors::UnsupportedImageType => e
-
raise ImageError.build_detailed_error(e, logo_attachment)
-
end
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class ImageError
-
4
def self.build_detailed_error(original_error, attachment)
-
1
blob = attachment.blob
-
1
details = extract_image_details(blob, attachment)
-
1
service_url = build_service_url(blob)
-
-
1
detailed_message = format_error_message(
-
original_error, details, service_url
-
)
-
-
1
original_error.class.new(detailed_message)
-
end
-
-
4
def self.extract_image_details(blob, attachment)
-
1
record = attachment.record
-
{
-
1
filename: blob.filename.to_s,
-
byte_size: blob.byte_size,
-
content_type: blob.content_type,
-
record_type: record.class.name,
-
record_id: record.try(:serial) || record.try(:id) || "unknown"
-
}
-
end
-
-
4
def self.build_service_url(blob)
-
1
"/rails/active_storage/blobs/#{blob.signed_id}/#{blob.filename}"
-
end
-
-
4
def self.format_error_message(original_error, details, service_url)
-
1
size_kb = (details[:byte_size] / 1024.0).round(2)
-
-
<<~MESSAGE
-
1
#{original_error.message}
-
-
Image details:
-
Filename: #{details[:filename]}
-
Size: #{details[:byte_size]} bytes (#{size_kb} KB)
-
Content-Type: #{details[:content_type]}
-
Record: #{details[:record_type]} #{details[:record_id]}
-
ActiveStorage URL: #{service_url}
-
MESSAGE
-
end
-
-
4
private_class_method :extract_image_details, :build_service_url,
-
:format_error_message
-
end
-
end
-
4
class PdfGeneratorService
-
4
class ImageOrientationProcessor
-
4
require "vips"
-
-
# Process image to handle EXIF orientation data
-
4
def self.process_with_orientation(image)
-
# Vips automatically handles EXIF orientation
-
# Just return the image as a buffer
-
2
image.write_to_buffer(".png")
-
end
-
-
# Get image dimensions after applying EXIF orientation correction
-
4
def self.get_dimensions(image)
-
# Vips automatically applies EXIF orientation
-
[image.width, image.height]
-
end
-
-
# Check if image needs orientation correction
-
4
def self.needs_orientation_correction?(image)
-
# Vips handles orientation automatically, so always return false
-
false
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class ImageProcessor
-
4
require "vips"
-
4
include Configuration
-
-
4
def self.generate_qr_code_header(pdf, entity)
-
69
qr_code_png = QrCodeService.generate_qr_code(entity)
-
# Position QR code at top left of page
-
68
qr_width, qr_height = PositionCalculator.qr_code_dimensions
-
# Use pdf.bounds.top to position from top of page
-
image_options = {
-
68
at: [0, pdf.bounds.top],
-
width: qr_width,
-
height: qr_height
-
}
-
68
pdf.image StringIO.new(qr_code_png), image_options
-
end
-
-
4
def self.add_unit_photo_footer(pdf, unit, column_count = 3)
-
69
then: 69
else: 0
then: 69
else: 0
else: 8
then: 61
return unless unit&.photo&.blob
-
-
# Calculate photo position in bottom right corner
-
8
pdf_width = pdf.bounds.width
-
-
# Calculate photo dimensions based on column count
-
8
attachment = unit.photo
-
8
image = create_image(attachment)
-
8
dimensions = calculate_footer_photo_dimensions(pdf, image, column_count)
-
8
photo_width, photo_height = dimensions
-
-
# Position photo in bottom right corner
-
8
photo_x = pdf_width - photo_width
-
# Account for footer height on first page
-
8
photo_y = calculate_photo_y(pdf, photo_height)
-
-
8
render_processed_image(pdf, image, photo_x, photo_y,
-
photo_width, photo_height, attachment)
-
rescue Prawn::Errors::UnsupportedImageType => e
-
raise ImageError.build_detailed_error(e, attachment)
-
end
-
-
4
def self.measure_unit_photo_height(pdf, unit, column_count = 3)
-
41
then: 40
else: 1
then: 40
else: 1
else: 2
then: 39
return 0 unless unit&.photo&.blob
-
-
2
attachment = unit.photo
-
2
image = create_image(attachment)
-
2
dimensions = calculate_footer_photo_dimensions(pdf, image, column_count)
-
2
_photo_width, photo_height = dimensions
-
-
2
then: 0
else: 2
if photo_height <= 0
-
raise I18n.t("pdf_generator.errors.zero_photo_height", unit_id: unit.id)
-
end
-
-
2
photo_height
-
rescue Prawn::Errors::UnsupportedImageType => e
-
raise ImageError.build_detailed_error(e, attachment)
-
end
-
-
4
def self.process_image_with_orientation(attachment)
-
2
image = create_image(attachment)
-
2
ImageOrientationProcessor.process_with_orientation(image)
-
end
-
-
4
def self.calculate_footer_photo_dimensions(pdf, image, column_count = 3)
-
10
original_width = image.width
-
10
original_height = image.height
-
-
# Calculate column width based on PDF width and column count
-
# Account for column spacers
-
10
spacer_count = column_count - 1
-
10
spacer_width = Configuration::ASSESSMENT_COLUMN_SPACER
-
10
total_spacer_width = spacer_width * spacer_count
-
10
column_width = (pdf.bounds.width - total_spacer_width) / column_count.to_f
-
-
# Photo width equals one column width
-
10
photo_width = column_width.round
-
-
# Calculate height maintaining aspect ratio
-
10
then: 0
if original_width.zero? || original_height.zero?
-
photo_height = photo_width
-
else: 10
else
-
10
aspect_ratio = original_width.to_f / original_height.to_f
-
10
photo_height = (photo_width / aspect_ratio).round
-
end
-
-
10
[photo_width, photo_height]
-
end
-
-
4
def self.render_processed_image(pdf, image, x, y, width, height, attachment)
-
# Vips automatically handles EXIF orientation
-
8
processed_image = image.write_to_buffer(".png")
-
-
image_options = {
-
8
at: [x, y],
-
width: width,
-
height: height
-
}
-
8
pdf.image StringIO.new(processed_image), image_options
-
rescue Prawn::Errors::UnsupportedImageType => e
-
raise ImageError.build_detailed_error(e, attachment)
-
end
-
-
4
def self.create_image(attachment)
-
12
image_data = attachment.blob.download
-
12
Vips::Image.new_from_buffer(image_data, "")
-
end
-
-
4
def self.calculate_photo_y(pdf, photo_height)
-
8
then: 8
if pdf.page_number == 1
-
8
Configuration::FOOTER_HEIGHT +
-
Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
-
else: 0
else
-
Configuration::QR_CODE_BOTTOM_OFFSET + photo_height
-
end
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class PhotosRenderer
-
4
def self.generate_photos_page(pdf, inspection)
-
46
else: 4
then: 42
return unless has_photos?(inspection)
-
-
4
pdf.start_new_page
-
4
add_photos_header(pdf)
-
-
4
max_photo_height = calculate_max_photo_height(pdf)
-
4
process_all_photos(pdf, inspection, max_photo_height)
-
end
-
-
4
def self.has_photos?(inspection)
-
48
inspection.photo_1.attached? ||
-
inspection.photo_2.attached? ||
-
inspection.photo_3.attached?
-
end
-
-
4
def self.add_photos_header(pdf)
-
header_options = {
-
5
size: Configuration::HEADER_TEXT_SIZE,
-
style: :bold
-
}
-
5
pdf.text I18n.t("pdf.inspection.photos_section"), header_options
-
5
pdf.stroke_horizontal_rule
-
5
pdf.move_down 15
-
end
-
-
4
def self.calculate_max_photo_height(pdf)
-
4
height_percent = Configuration::PHOTO_MAX_HEIGHT_PERCENT
-
4
pdf.bounds.height * height_percent
-
end
-
-
4
def self.process_all_photos(pdf, inspection, max_photo_height)
-
9
current_y = pdf.cursor
-
-
9
photo_fields.each do |photo_field, label|
-
26
photo = inspection.send(photo_field)
-
26
else: 18
then: 8
next unless photo.attached?
-
-
18
current_y = handle_page_break_if_needed(
-
pdf, current_y, max_photo_height
-
)
-
-
18
render_photo(pdf, photo, label, max_photo_height)
-
17
current_y = pdf.cursor - Configuration::PHOTO_SPACING
-
17
pdf.move_down Configuration::PHOTO_SPACING
-
end
-
end
-
-
4
def self.photo_fields
-
[
-
13
[:photo_1, I18n.t("pdf.inspection.fields.photo_1_label")],
-
[:photo_2, I18n.t("pdf.inspection.fields.photo_2_label")],
-
[:photo_3, I18n.t("pdf.inspection.fields.photo_3_label")]
-
]
-
end
-
-
4
def self.handle_page_break_if_needed(pdf, current_y, max_photo_height)
-
7
needed_space = calculate_needed_space(max_photo_height)
-
-
7
then: 2
if current_y < needed_space
-
2
pdf.start_new_page
-
2
pdf.cursor
-
else: 5
else
-
5
current_y
-
end
-
end
-
-
4
def self.calculate_needed_space(max_photo_height)
-
8
label_size = Configuration::PHOTO_LABEL_SIZE
-
8
label_spacing = Configuration::PHOTO_LABEL_SPACING
-
8
photo_spacing = Configuration::PHOTO_SPACING
-
8
max_photo_height + label_size + label_spacing + photo_spacing
-
end
-
-
4
def self.render_photo(pdf, photo, label, max_height)
-
13
photo.blob.download
-
13
processed_image = ImageProcessor.process_image_with_orientation(photo)
-
-
11
image_width, image_height = calculate_photo_dimensions_from_blob(
-
photo, pdf.bounds.width, max_height
-
)
-
11
x_position = (pdf.bounds.width - image_width) / 2
-
-
11
render_image_to_pdf(
-
pdf, processed_image, x_position, image_width, image_height, photo
-
)
-
-
10
add_photo_label(pdf, label, image_height)
-
rescue Prawn::Errors::UnsupportedImageType => e
-
3
raise ImageError.build_detailed_error(e, photo)
-
end
-
-
4
def self.calculate_photo_dimensions_from_blob(photo, max_width, max_height)
-
12
original_width = photo.blob.metadata[:width].to_f
-
12
original_height = photo.blob.metadata[:height].to_f
-
-
12
width_scale = max_width / original_width
-
12
height_scale = max_height / original_height
-
12
scale = [width_scale, height_scale].min
-
-
12
[original_width * scale, original_height * scale]
-
end
-
-
4
def self.render_image_to_pdf(pdf, image_data, x_position, width, height,
-
photo)
-
image_options = {
-
12
at: [x_position, pdf.cursor],
-
width: width,
-
height: height
-
}
-
12
pdf.image StringIO.new(image_data), image_options
-
rescue Prawn::Errors::UnsupportedImageType => e
-
1
raise ImageError.build_detailed_error(e, photo)
-
end
-
-
4
def self.add_photo_label(pdf, label, image_height)
-
6
pdf.move_down image_height + Configuration::PHOTO_LABEL_SPACING
-
label_options = {
-
6
size: Configuration::PHOTO_LABEL_SIZE,
-
align: :center
-
}
-
6
pdf.text label, label_options
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class PositionCalculator
-
4
include Configuration
-
-
# Calculate QR code position in top left corner
-
# QR code's top-left corner aligns with page top left, matching header spacing
-
4
def self.qr_code_position(pdf_bounds_width, pdf_page_number = 1)
-
4
x = QR_CODE_MARGIN
-
# In Prawn, Y coordinates are from bottom, so we need to calculate from page top
-
# This positions the QR code at the very top of the page
-
4
y = QR_CODE_SIZE
-
4
[x, y]
-
end
-
-
# Calculate photo position aligned with QR code
-
# Photo width is twice QR code size, height maintains aspect ratio
-
# Photo's bottom-right corner aligns with QR code's bottom-right corner
-
4
def self.photo_footer_position(qr_x, qr_y, photo_width = nil, photo_height = nil)
-
5
photo_width ||= QR_CODE_SIZE * 2
-
5
photo_height ||= photo_width # Default to square if no height provided
-
-
# Photo's right edge aligns with QR's right edge (both align with table right edge)
-
5
photo_x = qr_x + QR_CODE_SIZE - photo_width
-
-
# Photo's bottom edge aligns with QR's bottom edge (both match header spacing)
-
5
photo_y = qr_y - QR_CODE_SIZE + photo_height
-
-
5
[photo_x, photo_y]
-
end
-
-
# Calculate photo dimensions for footer (width = 2x QR size, height maintains aspect ratio)
-
# Note: original_width and original_height should be post-EXIF-rotation dimensions
-
4
def self.footer_photo_dimensions(original_width, original_height)
-
4
footer_photo_dimensions_with_multiplier(original_width, original_height, 2.0)
-
end
-
-
# Calculate photo dimensions with custom width multiplier
-
4
def self.footer_photo_dimensions_with_multiplier(original_width, original_height, width_multiplier)
-
4
target_width = (QR_CODE_SIZE * width_multiplier).round
-
-
4
then: 1
else: 3
return [target_width, target_width] if original_width.zero? || original_height.zero?
-
-
3
aspect_ratio = calculate_aspect_ratio(original_width, original_height)
-
3
target_height = (target_width / aspect_ratio).round
-
-
3
[target_width, target_height]
-
end
-
-
# Get QR code dimensions
-
4
def self.qr_code_dimensions
-
69
[QR_CODE_SIZE, QR_CODE_SIZE]
-
end
-
-
# Check if coordinates are within PDF bounds
-
4
def self.within_bounds?(x, y, width, height, pdf_bounds_width, pdf_bounds_height)
-
9
x >= 0 &&
-
y >= 0 &&
-
7
(x + width) <= pdf_bounds_width &&
-
5
(y + height) <= pdf_bounds_height
-
end
-
-
# Calculate aspect ratio for image fitting
-
4
def self.calculate_aspect_ratio(original_width, original_height)
-
15
then: 1
else: 14
return 1.0 if original_height.zero?
-
14
original_width.to_f / original_height.to_f
-
end
-
-
# Calculate dimensions to fit within constraints while maintaining aspect ratio
-
4
def self.fit_dimensions(original_width, original_height, max_width, max_height)
-
9
then: 2
else: 7
return [max_width, max_height] if original_width.zero? || original_height.zero?
-
-
# If original already fits within constraints, return original dimensions
-
7
then: 1
else: 6
if original_width <= max_width && original_height <= max_height
-
1
return [original_width, original_height]
-
end
-
-
6
aspect_ratio = calculate_aspect_ratio(original_width, original_height)
-
-
# Try fitting by width first
-
6
fitted_width = max_width
-
6
fitted_height = (fitted_width / aspect_ratio).round
-
-
# If height is too big, fit by height instead
-
6
then: 2
else: 4
if fitted_height > max_height
-
2
fitted_height = max_height
-
2
fitted_width = (fitted_height * aspect_ratio).round
-
end
-
-
6
[fitted_width, fitted_height]
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class PdfGeneratorService
-
4
class TableBuilder
-
4
include Configuration
-
-
4
def self.create_pdf_table(pdf, data)
-
table = pdf.table(data, width: pdf.bounds.width) do |t|
-
t.cells.borders = []
-
t.cells.padding = TABLE_CELL_PADDING
-
t.columns(0).font_style = :bold
-
t.columns(0).width = TABLE_FIRST_COLUMN_WIDTH
-
t.row(0..data.length - 1).background_color = "EEEEEE"
-
t.row(0..data.length - 1).borders = [:bottom]
-
t.row(0..data.length - 1).border_color = "DDDDDD"
-
end
-
-
then: 0
else: 0
yield table if block_given?
-
table
-
end
-
-
4
def self.create_nice_box_table(pdf, title, data)
-
23
pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
-
23
pdf.stroke_horizontal_rule
-
23
pdf.move_down 10
-
-
23
table = pdf.table(data, width: pdf.bounds.width) do |t|
-
23
t.cells.borders = []
-
23
t.cells.padding = NICE_TABLE_CELL_PADDING
-
23
t.cells.size = NICE_TABLE_TEXT_SIZE
-
23
t.columns(0).font_style = :bold
-
23
t.columns(0).width = TABLE_FIRST_COLUMN_WIDTH
-
23
t.row(0..data.length - 1).background_color = "EEEEEE"
-
23
t.row(0..data.length - 1).borders = [:bottom]
-
23
t.row(0..data.length - 1).border_color = "DDDDDD"
-
end
-
-
23
then: 0
else: 23
yield table if block_given?
-
23
pdf.move_down 15
-
23
table
-
end
-
-
4
def self.create_unit_details_table(pdf, title, data)
-
65
pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
-
65
pdf.stroke_horizontal_rule
-
65
pdf.move_down 10
-
-
65
table = create_styled_unit_table(pdf, data)
-
65
then: 0
else: 65
yield table if block_given?
-
65
pdf.move_down 15
-
65
table
-
end
-
-
4
def self.create_styled_unit_table(pdf, data)
-
65
is_unit_pdf = data.first.length == 2
-
-
65
pdf.table(data, width: pdf.bounds.width) do |t|
-
65
apply_unit_table_base_styling(t, data.length)
-
65
apply_unit_table_column_styling(t, is_unit_pdf, pdf.bounds.width)
-
end
-
end
-
-
4
def self.apply_unit_table_base_styling(table, row_count)
-
65
table.cells.borders = []
-
65
table.cells.padding = UNIT_TABLE_CELL_PADDING
-
65
table.cells.size = UNIT_TABLE_TEXT_SIZE
-
-
65
table.row(0..row_count - 1).background_color = "EEEEEE"
-
65
table.row(0..row_count - 1).borders = [:bottom]
-
65
table.row(0..row_count - 1).border_color = "DDDDDD"
-
end
-
-
4
def self.apply_unit_table_column_styling(table, is_unit_pdf, pdf_width)
-
65
table.columns(0).font_style = :bold
-
-
65
then: 25
if is_unit_pdf
-
25
table.columns(0).width = I18n.t("pdf.table.unit_label_column_width_left")
-
else: 40
else
-
40
apply_four_column_styling(table, pdf_width)
-
end
-
end
-
-
4
def self.apply_four_column_styling(table, pdf_width)
-
40
table.columns(2).font_style = :bold
-
-
40
left_width = I18n.t("pdf.table.unit_label_column_width_left")
-
40
right_width = I18n.t("pdf.table.unit_label_column_width_right")
-
-
40
table.columns(0).width = left_width
-
40
table.columns(2).width = right_width
-
-
40
remaining_width = pdf_width - (left_width + right_width)
-
40
table.columns(1).width = remaining_width / 2
-
40
table.columns(3).width = remaining_width / 2
-
end
-
-
4
def self.create_inspection_history_table(pdf, title, inspections)
-
2
pdf.text title, size: HEADER_TEXT_SIZE, style: :bold
-
2
pdf.stroke_horizontal_rule
-
2
pdf.move_down 10
-
-
2
table_data = build_inspection_history_data(inspections)
-
2
table = create_styled_history_table(pdf, table_data)
-
-
2
pdf.move_down 15
-
2
table
-
end
-
-
4
def self.build_inspection_history_data(inspections)
-
header = [
-
5
I18n.t("pdf.unit.fields.date"),
-
I18n.t("pdf.unit.fields.result"),
-
I18n.t("pdf.unit.fields.inspector")
-
]
-
-
5
data_rows = inspections.map do |inspection|
-
[
-
10
Utilities.format_date(inspection.inspection_date),
-
inspection_result_text(inspection),
-
inspector_text(inspection)
-
]
-
end
-
-
5
[header] + data_rows
-
end
-
-
4
def self.inspection_result_text(inspection)
-
12
then: 7
if inspection.passed
-
7
I18n.t("shared.pass_pdf")
-
else: 5
else
-
5
I18n.t("shared.fail_pdf")
-
end
-
end
-
-
4
def self.inspector_text(inspection)
-
13
inspector_name = inspection.user.name
-
13
rpii_number = inspection.user.rpii_inspector_number
-
-
13
then: 11
if rpii_number.present?
-
11
I18n.t("pdf.unit.fields.inspector_with_rpii",
-
name: inspector_name,
-
rpii_label: I18n.t("pdf.inspection.fields.rpii_inspector_no"),
-
rpii_number: rpii_number)
-
else: 2
else
-
2
inspector_name
-
end
-
end
-
-
4
def self.create_styled_history_table(pdf, table_data)
-
2
pdf.table(table_data, width: pdf.bounds.width) do |t|
-
2
apply_history_table_base_styling(t)
-
2
apply_history_table_row_styling(t, table_data)
-
2
apply_history_table_column_widths(t, pdf.bounds.width)
-
end
-
end
-
-
4
def self.apply_history_table_base_styling(table)
-
2
table.cells.padding = NICE_TABLE_CELL_PADDING
-
2
table.cells.size = HISTORY_TABLE_TEXT_SIZE
-
2
table.cells.border_width = 0.5
-
2
table.cells.border_color = "CCCCCC"
-
-
2
table.row(0).background_color = HISTORY_TABLE_HEADER_COLOR
-
2
table.row(0).font_style = :bold
-
end
-
-
4
def self.apply_history_table_row_styling(table, table_data)
-
2
(1...table_data.length).each do |i|
-
6
apply_row_background_color(table, i)
-
6
apply_result_cell_styling(table, i, table_data[i][1])
-
end
-
end
-
-
4
def self.apply_row_background_color(table, row_index)
-
6
then: 3
color = if row_index.odd?
-
3
HISTORY_TABLE_ROW_COLOR
-
else: 3
else
-
3
HISTORY_TABLE_ALT_ROW_COLOR
-
end
-
6
table.row(row_index).background_color = color
-
end
-
-
4
def self.apply_result_cell_styling(table, row_index, result_text)
-
6
result_cell = table.row(row_index).column(1)
-
-
6
then: 4
if result_text == I18n.t("shared.pass_pdf")
-
4
result_cell.text_color = PASS_COLOR
-
4
else: 2
result_cell.font_style = :bold
-
2
then: 2
else: 0
elsif result_text == I18n.t("shared.fail_pdf")
-
2
result_cell.text_color = FAIL_COLOR
-
2
result_cell.font_style = :bold
-
end
-
end
-
-
4
def self.apply_history_table_column_widths(table, pdf_width)
-
2
date_width = HISTORY_DATE_COLUMN_WIDTH
-
2
result_width = HISTORY_RESULT_COLUMN_WIDTH
-
2
inspector_width = pdf_width - date_width - result_width
-
-
2
table.column_widths = [date_width, result_width, inspector_width]
-
end
-
-
4
def self.build_unit_details_table(unit, context)
-
# Get dimensions from last inspection if available
-
30
last_inspection = unit.last_inspection
-
30
then: 26
if context == :unit
-
26
build_unit_details_table_for_unit_pdf(unit, last_inspection)
-
else: 4
else
-
4
build_unit_details_table_with_inspection(unit, last_inspection, context)
-
end
-
end
-
-
4
def self.build_unit_details_table_for_unit_pdf(unit, last_inspection)
-
26
dimensions = []
-
-
26
then: 2
else: 24
if last_inspection
-
2
then: 2
else: 0
if last_inspection.width.present?
-
2
dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :width).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.width)}"
-
end
-
2
then: 2
else: 0
if last_inspection.length.present?
-
2
dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :length).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.length)}"
-
end
-
2
then: 2
else: 0
if last_inspection.height.present?
-
2
dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :height).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.height)}"
-
end
-
end
-
26
then: 2
else: 24
dimensions_text = dimensions.any? ? dimensions.join(" ") : ""
-
-
# Build simple two-column table for unit PDFs
-
[
-
26
[ChobbleForms::FieldUtils.form_field_label(:units, :name),
-
Utilities.truncate_text(unit.name, UNIT_NAME_MAX_LENGTH)],
-
[ChobbleForms::FieldUtils.form_field_label(:units, :manufacturer), unit.manufacturer],
-
[ChobbleForms::FieldUtils.form_field_label(:units, :operator), unit.operator],
-
[ChobbleForms::FieldUtils.form_field_label(:units, :serial), unit.serial],
-
[I18n.t("pdf.inspection.fields.size_m"), dimensions_text]
-
]
-
end
-
-
4
def self.build_unit_details_table_with_inspection(unit, last_inspection, context)
-
44
dimensions = []
-
-
44
then: 40
else: 4
if last_inspection
-
40
then: 25
else: 15
if last_inspection.width.present?
-
25
dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :width).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.width)}"
-
end
-
40
then: 25
else: 15
if last_inspection.length.present?
-
25
dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :length).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.length)}"
-
end
-
40
then: 25
else: 15
if last_inspection.height.present?
-
25
dimensions << "#{ChobbleForms::FieldUtils.form_field_label(:inspection, :height).sub(" (m)", "")}: #{Utilities.format_dimension(last_inspection.height)}"
-
end
-
end
-
44
then: 25
else: 19
dimensions_text = dimensions.any? ? dimensions.join(" ") : ""
-
-
# Get inspector details from current inspection (for inspection PDF) or last inspection (for unit PDF)
-
44
then: 43
inspection = if context == :inspection
-
43
last_inspection
-
else: 1
else
-
1
unit.last_inspection
-
end
-
44
then: 40
else: 4
then: 40
else: 4
inspector_name = inspection&.user&.name
-
44
then: 40
else: 4
then: 40
else: 4
rpii_number = inspection&.user&.rpii_inspector_number
-
-
# Combine inspector name with RPII number if present
-
44
then: 38
inspector_text = if rpii_number.present?
-
38
"#{inspector_name} (#{I18n.t("pdf.inspection.fields.rpii_inspector_no")} #{rpii_number})"
-
else: 6
else
-
6
inspector_name
-
end
-
-
44
then: 40
else: 4
then: 40
else: 4
issued_date = if inspection&.inspection_date
-
40
Utilities.format_date(inspection.inspection_date)
-
end
-
-
# Build the table rows
-
[
-
[
-
44
ChobbleForms::FieldUtils.form_field_label(:units, :name),
-
Utilities.truncate_text(unit.name, UNIT_NAME_MAX_LENGTH),
-
I18n.t("pdf.inspection.fields.inspected_by"),
-
inspector_text
-
],
-
[
-
ChobbleForms::FieldUtils.form_field_label(:units, :description),
-
unit.description,
-
ChobbleForms::FieldUtils.form_field_label(:units, :manufacturer),
-
unit.manufacturer
-
],
-
[
-
I18n.t("pdf.inspection.fields.size_m"),
-
dimensions_text,
-
ChobbleForms::FieldUtils.form_field_label(:units, :operator),
-
unit.operator
-
],
-
[
-
ChobbleForms::FieldUtils.form_field_label(:units, :serial),
-
unit.serial,
-
I18n.t("pdf.inspection.fields.issued_date"),
-
issued_date
-
]
-
]
-
end
-
end
-
end
-
4
class PdfGeneratorService
-
4
class Utilities
-
4
include Configuration
-
-
4
def self.truncate_text(text, max_length)
-
77
then: 3
else: 74
return "" if text.nil?
-
74
then: 3
else: 71
(text.length > max_length) ? "#{text[0...max_length]}..." : text
-
end
-
-
4
def self.format_dimension(value)
-
85
then: 1
else: 84
return "" if value.nil?
-
84
value.to_s.sub(/\.0$/, "")
-
end
-
-
4
def self.format_date(date)
-
95
then: 1
else: 94
return I18n.t("pdf.inspection.fields.na") if date.nil?
-
94
date.strftime("%-d %B, %Y")
-
end
-
-
4
def self.format_pass_fail(value)
-
7
when: 2
case value
-
2
when: 2
when true then I18n.t("shared.pass_pdf")
-
2
else: 3
when false then I18n.t("shared.fail_pdf")
-
3
else I18n.t("pdf.inspection.fields.na")
-
end
-
end
-
-
4
def self.format_measurement(value, unit = "")
-
7
then: 3
else: 4
return I18n.t("pdf.inspection.fields.na") if value.nil?
-
4
"#{value}#{unit}"
-
end
-
-
4
def self.add_draft_watermark(pdf)
-
# Add 3x3 grid of DRAFT watermarks to each page
-
(1..pdf.page_count).each do |page_num|
-
pdf.go_to_page(page_num)
-
-
pdf.transparent(WATERMARK_TRANSPARENCY) do
-
pdf.fill_color "FF0000"
-
-
# 3x3 grid positions
-
y_positions = [0.10, 0.30, 0.50, 0.70, 0.9].map { |pct| pdf.bounds.height * pct }
-
x_positions = [0.15, 0.50, 0.85].map { |pct| pdf.bounds.width * pct - (WATERMARK_WIDTH / 2) }
-
-
y_positions.each do |y|
-
x_positions.each do |x|
-
pdf.text_box I18n.t("pdf.inspection.watermark.draft"),
-
at: [x, y],
-
width: WATERMARK_WIDTH,
-
height: WATERMARK_HEIGHT,
-
size: WATERMARK_TEXT_SIZE,
-
style: :bold,
-
align: :center,
-
valign: :top
-
end
-
end
-
end
-
-
pdf.fill_color "000000"
-
end
-
end
-
end
-
end
-
4
class PhotoProcessingService
-
4
require "vips"
-
-
# Process uploaded photo data: resize to max 1200px, convert to JPEG 75%
-
4
def self.process_upload_data(image_data, original_filename = "photo")
-
20
then: 0
else: 20
return nil if image_data.blank?
-
-
begin
-
20
image = Vips::Image.new_from_buffer(image_data, "")
-
19
image = resize_image(image)
-
19
then: 0
else: 19
image = add_white_background(image) if image.has_alpha?
-
19
processed_data = image.jpegsave_buffer(Q: 75, strip: true)
-
19
processed_filename = change_extension_to_jpg(original_filename)
-
-
{
-
19
io: StringIO.new(processed_data),
-
filename: processed_filename,
-
content_type: "image/jpeg"
-
}
-
rescue => e
-
1
Rails.logger.error "Photo processing failed: #{e.message}"
-
1
nil
-
end
-
end
-
-
4
def self.process_upload(uploaded_file)
-
14
then: 0
else: 14
return nil if uploaded_file.blank?
-
-
14
then: 14
else: 0
uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
-
-
14
process_upload_data(uploaded_file.read, uploaded_file.original_filename)
-
end
-
-
# Validate that data is a processable image
-
4
def self.valid_image_data?(image_data)
-
21
then: 2
else: 19
return false if image_data.blank?
-
-
19
image = Vips::Image.new_from_buffer(image_data, "")
-
# Try to get basic image properties to ensure it's valid
-
15
image.width && image.height
-
15
true
-
rescue Vips::Error
-
4
false
-
end
-
-
4
def self.valid_image?(uploaded_file)
-
17
then: 0
else: 17
return false if uploaded_file.blank?
-
-
17
then: 17
else: 0
uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
-
-
17
data = uploaded_file.read
-
17
then: 17
else: 0
uploaded_file.rewind if uploaded_file.respond_to?(:rewind)
-
-
17
valid_image_data?(data)
-
end
-
-
4
def self.change_extension_to_jpg(filename)
-
19
then: 0
else: 19
return "photo.jpg" if filename.blank?
-
-
19
basename = File.basename(filename, ".*")
-
19
"#{basename}.jpg"
-
end
-
-
4
def self.resize_image(image)
-
19
max_size = ImageProcessorService::FULL_SIZE
-
19
else: 19
then: 0
return image unless image.width > max_size || image.height > max_size
-
-
19
scale = [max_size.to_f / image.width, max_size.to_f / image.height].min
-
19
image.resize(scale)
-
end
-
-
4
def self.add_white_background(image)
-
background = Vips::Image.black(image.width, image.height).add(255)
-
background.composite2(image, :over)
-
end
-
-
4
private_class_method :change_extension_to_jpg, :resize_image,
-
:add_white_background
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
4
class QrCodeService
-
4
extend T::Sig
-
-
8
sig { params(record: T.any(Inspection, Unit)).returns(T.nilable(String)) }
-
4
def self.generate_qr_code(record)
-
80
require "rqrcode"
-
-
# Create QR code for the report URL using the shorter format
-
80
then: 48
if record.is_a?(Inspection)
-
48
else: 32
generate_inspection_qr_code(record)
-
32
then: 32
else: 0
elsif record.is_a?(Unit)
-
32
generate_unit_qr_code(record)
-
end
-
end
-
-
8
sig { params(inspection: Inspection).returns(String) }
-
4
def self.generate_inspection_qr_code(inspection)
-
49
require "rqrcode"
-
-
49
base_url = T.must(ENV["BASE_URL"])
-
48
url = "#{base_url}/inspections/#{inspection.id}"
-
48
generate_qr_code_from_url(url)
-
end
-
-
8
sig { params(unit: Unit).returns(String) }
-
4
def self.generate_unit_qr_code(unit)
-
33
require "rqrcode"
-
-
33
base_url = T.must(ENV["BASE_URL"])
-
32
url = "#{base_url}/units/#{unit.id}"
-
32
generate_qr_code_from_url(url)
-
end
-
-
8
sig { params(url: String).returns(String) }
-
4
def self.generate_qr_code_from_url(url)
-
# Create QR code with optimized options for chunkier appearance
-
82
qrcode = RQRCode::QRCode.new(url, qr_code_options)
-
82
qrcode.as_png(png_options).to_blob
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def self.qr_code_options
-
83
{
-
# Use lower error correction level for fewer modules (chunkier code)
-
# :l - 7% error correction (lowest, largest modules)
-
# :m - 15% error correction
-
# :q - 25% error correction
-
# :h - 30% error correction (highest, smallest modules)
-
level: :m
-
}
-
end
-
-
8
sig { returns(T::Hash[Symbol, T.untyped]) }
-
4
def self.png_options
-
83
{
-
bit_depth: 1,
-
border_modules: 0, # No border for proper alignment
-
color_mode: ChunkyPNG::COLOR_GRAYSCALE,
-
color: "black",
-
file: nil,
-
fill: "white",
-
module_px_size: 8, # Larger modules
-
resize_exactly_to: false,
-
resize_gte_to: false,
-
size: 300
-
}
-
end
-
end
-
# typed: strict
-
# frozen_string_literal: true
-
-
# Service to verify RPII inspector numbers using the official API
-
4
require "net/http"
-
4
require "uri"
-
4
require "json"
-
-
4
class RpiiVerificationService
-
4
extend T::Sig
-
-
4
BASE_URL = "https://www.playinspectors.com/wp-admin/admin-ajax.php"
-
4
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
-
"AppleWebKit/537.36 (KHTML, like Gecko) " \
-
"Chrome/91.0.4472.124 Safari/537.36"
-
-
4
InspectorInfo = T.type_alias do
-
{
-
name: T.nilable(String),
-
number: T.nilable(String),
-
qualifications: T.nilable(String),
-
id: T.nilable(String),
-
raw_value: String
-
}
-
end
-
-
4
VerificationResult = T.type_alias do
-
{
-
valid: T::Boolean,
-
inspector: T.nilable(InspectorInfo)
-
}
-
end
-
-
4
class << self
-
4
extend T::Sig
-
-
4
sig do
-
params(inspector_number: T.nilable(T.any(String, Integer)))
-
.returns(T::Array[InspectorInfo])
-
end
-
4
def search(inspector_number)
-
then: 0
else: 0
return [] if inspector_number.blank?
-
-
response = make_api_request(inspector_number)
-
-
then: 0
if response.code == "200"
-
parse_response(JSON.parse(response.body))
-
else: 0
else
-
log_error(response)
-
[]
-
end
-
end
-
-
4
sig do
-
params(inspector_number: T.nilable(T.any(String, Integer)))
-
.returns(VerificationResult)
-
end
-
4
def verify(inspector_number)
-
results = search(inspector_number)
-
-
then: 0
else: 0
inspector = results.find { |r| r[:number]&.to_s == inspector_number.to_s }
-
-
then: 0
if inspector
-
{valid: true, inspector: inspector}
-
else: 0
else
-
{valid: false, inspector: nil}
-
end
-
end
-
-
4
private
-
-
4
sig do
-
params(inspector_number: T.any(String, Integer))
-
.returns(Net::HTTPResponse)
-
end
-
4
def make_api_request(inspector_number)
-
uri = URI.parse(BASE_URL)
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = build_request(uri.path, inspector_number)
-
http.request(request)
-
end
-
-
4
sig do
-
params(path: String, inspector_number: T.any(String, Integer))
-
.returns(Net::HTTP::Post)
-
end
-
4
def build_request(path, inspector_number)
-
request = Net::HTTP::Post.new(path)
-
request["User-Agent"] = USER_AGENT
-
content_type = "application/x-www-form-urlencoded; charset=UTF-8"
-
request["Content-Type"] = content_type
-
request["X-Requested-With"] = "XMLHttpRequest"
-
-
request.body = URI.encode_www_form({
-
action: "check_inspector_ajax",
-
search: inspector_number.to_s.strip
-
})
-
-
request
-
end
-
-
4
sig { params(response: Net::HTTPResponse).void }
-
4
def log_error(response)
-
error_msg = "RPII verification failed: #{response.code}"
-
Rails.logger.error "#{error_msg} - #{response.body}"
-
end
-
-
4
sig { params(response: T.untyped).returns(T::Array[InspectorInfo]) }
-
4
def parse_response(response)
-
suggestions = extract_suggestions(response)
-
else: 0
then: 0
return [] unless suggestions.is_a?(Array)
-
-
suggestions.map { |item| parse_inspector_item(item) }
-
end
-
-
4
sig { params(response: T.untyped).returns(T.untyped) }
-
4
def extract_suggestions(response)
-
then: 0
if response.is_a?(Hash) && response["suggestions"]
-
else: 0
response["suggestions"]
-
then: 0
elsif response.is_a?(Array)
-
response
-
else: 0
else
-
[]
-
end
-
end
-
-
4
sig { params(item: T.untyped).returns(InspectorInfo) }
-
4
def parse_inspector_item(item)
-
value = item["value"] || ""
-
data = item["data"]
-
-
then: 0
if /^(.+?)\s*\((.+?)\)$/.match?(value)
-
else: 0
parse_name_qualifications_format(value, data)
-
then: 0
elsif /^(.+?)\s*\((\d+)\)\s*-\s*(.+)$/.match?(value)
-
parse_name_number_qualifications_format(value, data)
-
else: 0
else
-
{
-
raw_value: value,
-
number: data,
-
id: data
-
}
-
end
-
end
-
-
4
sig { params(value: String, data: T.untyped).returns(InspectorInfo) }
-
4
def parse_name_qualifications_format(value, data)
-
{
-
name: T.must($1).strip,
-
number: data, # The data field contains the inspector number
-
qualifications: T.must($2).strip,
-
id: data,
-
raw_value: value
-
}
-
end
-
-
4
sig { params(value: String, data: T.untyped).returns(InspectorInfo) }
-
4
def parse_name_number_qualifications_format(value, data)
-
{
-
name: T.must($1).strip,
-
number: $2,
-
qualifications: T.must($3).strip,
-
id: data,
-
raw_value: value
-
}
-
end
-
end
-
end
-
# typed: false
-
# frozen_string_literal: true
-
-
4
class S3BackupService
-
4
include S3Helpers
-
4
include S3BackupOperations
-
-
4
def perform
-
ensure_s3_enabled
-
validate_s3_config
-
-
service = get_s3_service
-
FileUtils.mkdir_p(temp_dir)
-
-
timestamp = Time.current.strftime("%Y-%m-%d")
-
backup_filename = "database-#{timestamp}.sqlite3"
-
temp_backup_path = temp_dir.join(backup_filename)
-
s3_key = "#{backup_dir}/database-#{timestamp}.tar.gz"
-
-
# Create SQLite backup
-
Rails.logger.info "Creating database backup..."
-
system("sqlite3", database_path.to_s, ".backup '#{temp_backup_path}'", exception: true)
-
Rails.logger.info "Database backup created successfully"
-
-
# Compress the backup
-
Rails.logger.info "Compressing backup..."
-
temp_compressed_path = create_tar_gz(timestamp)
-
Rails.logger.info "Backup compressed successfully"
-
-
# Upload to S3
-
Rails.logger.info "Uploading to S3 (#{s3_key})..."
-
File.open(temp_compressed_path, "rb") do |file|
-
service.upload(s3_key, file)
-
end
-
Rails.logger.info "Backup uploaded to S3 successfully"
-
-
# Clean up old backups
-
Rails.logger.info "Cleaning up old backups..."
-
deleted_count = cleanup_old_backups(service)
-
then: 0
else: 0
Rails.logger.info "Deleted #{deleted_count} old backups" if deleted_count.positive?
-
-
backup_size_mb = (File.size(temp_compressed_path) / 1024.0 / 1024.0).round(2)
-
Rails.logger.info "Database backup completed successfully!"
-
Rails.logger.info "Backup location: #{s3_key}"
-
Rails.logger.info "Backup size: #{backup_size_mb} MB"
-
-
{
-
success: true,
-
location: s3_key,
-
size_mb: backup_size_mb,
-
deleted_count: deleted_count
-
}
-
ensure
-
then: 0
else: 0
FileUtils.rm_f(temp_backup_path) if defined?(temp_backup_path)
-
then: 0
else: 0
FileUtils.rm_f(temp_dir.join("database-#{timestamp}.tar.gz")) if defined?(timestamp)
-
end
-
end
-
# typed: strict
-
-
4
class SeedDataService
-
4
extend T::Sig
-
4
CASTLE_IMAGE_COUNT = T.let(5, Integer)
-
4
UNIT_COUNT = T.let(20, Integer)
-
4
INSPECTION_COUNT = T.let(5, Integer)
-
4
INSPECTION_INTERVAL_DAYS = T.let(364, Integer)
-
4
INSPECTION_OFFSET_RANGE = T.let(0..365, T::Range[Integer])
-
4
INSPECTION_DURATION_RANGE = T.let(1..4, T::Range[Integer])
-
4
HIGH_PASS_RATE = T.let(0.95, Float)
-
4
NORMAL_PASS_RATE = T.let(0.85, Float)
-
-
# Stefan-variant owner names as per existing seeds
-
STEFAN_OWNER_NAMES = [
-
4
"Stefan's Bouncers",
-
"Steph's Castles",
-
"Steve's Inflatables",
-
"Stefano's Party Hire",
-
"Stef's Fun Factory",
-
"Stefan Family Inflatables",
-
"Stephan's Adventure Co",
-
"Estephan Events",
-
"Steff's Soft Play"
-
].freeze
-
-
4
class << self
-
4
extend T::Sig
-
-
5
sig { params(user: User, unit_count: Integer, inspection_count: Integer).returns(T::Boolean) }
-
4
def add_seeds_for_user(user, unit_count: UNIT_COUNT, inspection_count: INSPECTION_COUNT)
-
12
then: 1
else: 11
raise "User already has seed data" if user.has_seed_data?
-
-
11
ActiveRecord::Base.transaction do
-
11
Rails.logger.info I18n.t("seed_data.logging.starting_creation", user_id: user.id)
-
11
ensure_castle_blobs_exist
-
11
Rails.logger.info I18n.t("seed_data.logging.castle_images_found", count: @castle_images.size)
-
11
create_seed_units_for_user(user, unit_count, inspection_count)
-
10
Rails.logger.info I18n.t("seed_data.logging.creation_completed")
-
end
-
10
true
-
end
-
-
5
sig { params(user: User).returns(T::Boolean) }
-
4
def delete_seeds_for_user(user)
-
5
ActiveRecord::Base.transaction do
-
# Delete inspections first (due to foreign key constraints)
-
5
user.inspections.seed_data.destroy_all
-
# Then delete units with preloaded attachments to avoid N+1
-
4
user.units.seed_data.includes(photo_attachment: :blob, cached_pdf_attachment: :blob).destroy_all
-
end
-
4
true
-
end
-
-
4
private
-
-
5
sig { void }
-
4
def ensure_castle_blobs_exist
-
11
@castle_images = T.let([], T::Array[T::Hash[Symbol, T.untyped]])
-
-
11
(1..CASTLE_IMAGE_COUNT).each do |i|
-
55
filename = "castle-#{i}.jpg"
-
55
filepath = Rails.root.join("app/assets/castles", filename)
-
-
55
else: 55
then: 0
next unless File.exist?(filepath)
-
-
# Read and cache the file content in memory
-
55
@castle_images << {
-
filename: filename,
-
content: File.read(filepath, mode: "rb") # Read in binary mode for images
-
}
-
end
-
-
# If no castle images found, don't fail - just log
-
11
then: 0
else: 11
Rails.logger.warn I18n.t("seed_data.logging.no_castle_images") if @castle_images.empty?
-
end
-
-
5
sig { params(user: User, unit_count: Integer, inspection_count: Integer).void }
-
4
def create_seed_units_for_user(user, unit_count, inspection_count)
-
# Mix of unit types similar to existing seeds
-
unit_configs = [
-
11
{name: "Medieval Castle Bouncer", manufacturer: "Airquee Manufacturing Ltd", width: 4.5, length: 4.5, height: 3.5},
-
{name: "Giant Party Castle", manufacturer: "Bouncy Castle Boys", width: 9.0, length: 9.0, height: 4.5},
-
{name: "Princess Castle with Slide", manufacturer: "Jump4Joy Inflatables", width: 5.5, length: 7.0, height: 4.0, has_slide: true},
-
{name: "Toddler Soft Play Centre", manufacturer: "Custom Inflatables UK", width: 6.0, length: 6.0, height: 2.5, is_totally_enclosed: true},
-
{name: "Assault Course Challenge", manufacturer: "Inflatable World Ltd", width: 3.0, length: 12.0, height: 3.5, has_slide: true},
-
{name: "Mega Slide Experience", manufacturer: "Airquee Manufacturing Ltd", width: 5.0, length: 15.0, height: 7.5, has_slide: true},
-
{name: "Gladiator Duel Platform", manufacturer: "Happy Hop Europe", width: 6.0, length: 6.0, height: 1.5},
-
{name: "Double Bungee Run", manufacturer: "Party Castle Manufacturers", width: 4.0, length: 10.0, height: 2.5}
-
]
-
-
# Pre-generate all unit IDs to avoid N+1 queries
-
11
unit_ids = generate_unit_ids_batch(user, unit_count)
-
-
# Pre-load existing unit IDs to avoid repeated existence checks
-
11
existing_ids = user.units.pluck(:id).to_set
-
-
11
unit_count.times do |i|
-
150
config = unit_configs[i % unit_configs.length]
-
150
unit = create_unit_from_config(user, config, i, unit_ids[i], existing_ids)
-
# Make half of units have incomplete most recent inspection
-
149
should_have_incomplete_inspection = i.even?
-
149
create_inspections_for_unit(unit, user, config, inspection_count, has_incomplete_recent: should_have_incomplete_inspection)
-
end
-
end
-
-
5
sig { params(user: User, count: Integer).returns(T::Array[String]) }
-
4
def generate_unit_ids_batch(user, count)
-
11
ids = []
-
11
existing_ids = user.units.pluck(:id).to_set
-
-
11
count.times do
-
169
loop do
-
169
id = SecureRandom.alphanumeric(CustomIdGenerator::ID_LENGTH).upcase
-
169
else: 0
then: 169
unless existing_ids.include?(id)
-
169
ids << id
-
169
existing_ids << id
-
169
break
-
end
-
end
-
end
-
-
11
ids
-
end
-
-
5
sig { params(user: User, config: T::Hash[Symbol, T.untyped], index: Integer, unit_id: String, existing_ids: T::Set[String]).returns(Unit) }
-
4
def create_unit_from_config(user, config, index, unit_id, existing_ids)
-
150
unit = user.units.build(
-
id: unit_id,
-
name: "#{config[:name]} ##{index + 1}",
-
serial: "SEED-#{Date.current.year}-#{SecureRandom.hex(4).upcase}",
-
description: generate_description(config[:name]),
-
manufacturer: config[:manufacturer],
-
operator: STEFAN_OWNER_NAMES.sample,
-
is_seed: true
-
)
-
150
unit.save!
-
-
# Attach random castle image if available
-
# For test environment, skip images as castle files don't exist
-
149
then: 0
else: 149
if @castle_images.any? && !Rails.env.test?
-
castle_image = @castle_images.sample
-
# Create a new attachment - ActiveStorage will dedupe the blob automatically
-
unit.photo.attach(
-
io: StringIO.new(castle_image[:content]),
-
filename: castle_image[:filename],
-
content_type: "image/jpeg"
-
)
-
end
-
-
149
unit
-
end
-
-
5
sig { params(name: String).returns(String) }
-
4
def generate_description(name)
-
150
case name
-
when: 73
when /Castle/
-
73
I18n.t("seed_data.descriptions.traditional_castle")
-
when: 14
when /Slide/
-
14
I18n.t("seed_data.descriptions.combination_slide")
-
when: 21
when /Soft Play/
-
21
I18n.t("seed_data.descriptions.soft_play")
-
when: 14
when /Assault Course/
-
14
I18n.t("seed_data.descriptions.assault_course")
-
when: 14
when /Gladiator/
-
14
I18n.t("seed_data.descriptions.gladiator")
-
when: 14
when /Bungee/
-
14
I18n.t("seed_data.descriptions.bungee_run")
-
else: 0
else
-
I18n.t("seed_data.descriptions.default")
-
end
-
end
-
-
5
sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], inspection_count: Integer, has_incomplete_recent: T::Boolean).void }
-
4
def create_inspections_for_unit(unit, user, config, inspection_count, has_incomplete_recent: false)
-
149
offset_days = rand(INSPECTION_OFFSET_RANGE)
-
-
149
inspection_count.times do |i|
-
718
create_single_inspection(unit, user, config, offset_days, i, has_incomplete_recent)
-
end
-
end
-
-
5
sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], offset_days: Integer, index: Integer, has_incomplete_recent: T::Boolean).void }
-
4
def create_single_inspection(unit, user, config, offset_days, index, has_incomplete_recent)
-
718
inspection_date = calculate_inspection_date(offset_days, index)
-
718
passed = determine_pass_status(index)
-
718
is_complete = !(index == 0 && has_incomplete_recent)
-
-
718
inspection = user.inspections.create!(
-
build_inspection_attributes(unit, user, config, inspection_date, passed, is_complete)
-
)
-
-
718
create_assessments_for_inspection(inspection, unit, config, passed: passed)
-
end
-
-
5
sig { params(offset_days: Integer, index: Integer).returns(Date) }
-
4
def calculate_inspection_date(offset_days, index)
-
718
days_ago = offset_days + (index * INSPECTION_INTERVAL_DAYS)
-
718
Date.current - days_ago.days
-
end
-
-
5
sig { params(index: Integer).returns(T::Boolean) }
-
4
def determine_pass_status(index)
-
718
then: 149
else: 569
(index == 0) ? (rand < HIGH_PASS_RATE) : (rand < NORMAL_PASS_RATE)
-
end
-
-
5
sig { params(unit: Unit, user: User, config: T::Hash[Symbol, T.untyped], inspection_date: Date, passed: T::Boolean, is_complete: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
-
4
def build_inspection_attributes(unit, user, config, inspection_date, passed, is_complete)
-
{
-
718
unit: unit,
-
inspector_company: user.inspection_company,
-
inspection_date: inspection_date,
-
718
then: 642
complete_date: is_complete ?
-
642
else: 76
inspection_date.to_time + rand(INSPECTION_DURATION_RANGE).hours :
-
76
nil,
-
is_seed: true,
-
718
then: 642
else: 76
passed: is_complete ? passed : nil,
-
risk_assessment: generate_risk_assessment(passed),
-
# Copy dimensions from config
-
width: config[:width],
-
length: config[:length],
-
height: config[:height],
-
has_slide: config[:has_slide] || false,
-
is_totally_enclosed: config[:is_totally_enclosed] || false,
-
indoor_only: [true, false].sample
-
}
-
end
-
-
5
sig { params(passed: T::Boolean).returns(String) }
-
4
def generate_risk_assessment(passed)
-
718
then: 637
if passed
-
[
-
637
"Unit inspected and found to be in good operational condition. All safety features functioning correctly. Suitable for continued use with standard supervision requirements.",
-
"Comprehensive safety assessment completed. Unit meets all EN 14960:2019 requirements. No significant hazards identified. Regular maintenance schedule should be maintained.",
-
"Risk assessment indicates low risk profile. All structural elements secure, adequate ventilation present, and safety markings clearly visible. Recommend continued operation with routine checks.",
-
"Safety evaluation satisfactory. Anchoring system robust, materials show no signs of degradation. Unit provides safe environment for users within specified age and height limits.",
-
"Full risk assessment completed with no critical issues identified. Minor wear noted on high-traffic areas but within acceptable limits. Unit certified safe for public use.",
-
"Detailed inspection reveals unit maintains structural integrity. All seams intact, proper inflation pressure maintained. Risk level assessed as minimal with appropriate supervision.",
-
"Unit passes comprehensive safety review. Emergency exits clearly marked and functional. Blower system operating within specifications. Low risk rating assigned.",
-
"Risk evaluation complete. Unit demonstrates good stability under load conditions. Safety padding adequate where required. Suitable for continued commercial operation."
-
].sample
-
else: 81
else
-
[
-
81
"Risk assessment identifies multiple safety concerns requiring immediate attention. Unit should not be used until repairs completed and re-inspected. High risk rating assigned.",
-
"Critical safety deficiencies noted during inspection. Structural integrity compromised in several areas. Unit poses unacceptable risk to users and must be withdrawn from service.",
-
"Significant hazards identified including inadequate anchoring and material degradation. Risk level unacceptable for public use. Comprehensive repairs required before recertification.",
-
"Safety assessment failed. Multiple non-conformances with EN 14960:2019 identified. Unit presents substantial risk of injury. Recommend immediate decommissioning or major refurbishment.",
-
"High risk factors present including compromised seams and insufficient inflation. Unit unsafe for operation. Client advised to cease use pending extensive remedial work.",
-
"Risk evaluation reveals dangerous conditions. Emergency exits partially obstructed, significant wear to load-bearing elements. Unit fails safety standards and requires urgent attention.",
-
"Assessment indicates elevated risk profile due to equipment failures and material defects. Unit not suitable for use. Full replacement of critical components necessary."
-
].sample
-
end
-
end
-
-
5
sig { params(inspection: Inspection, unit: Unit, config: T::Hash[Symbol, T.untyped], passed: T::Boolean).void }
-
4
def create_assessments_for_inspection(inspection, unit, config, passed: true)
-
718
is_incomplete = inspection.complete_date.nil?
-
-
718
inspection.each_applicable_assessment do |assessment_key, assessment_class, _|
-
3584
assessment_type = assessment_key.to_s.sub(/_assessment$/, "")
-
-
3584
create_assessment(
-
inspection,
-
assessment_key,
-
assessment_type,
-
passed,
-
is_incomplete
-
)
-
end
-
end
-
-
5
sig { params(inspection: Inspection, assessment_key: Symbol, assessment_type: String, passed: T::Boolean, is_incomplete: T::Boolean).void }
-
4
def create_assessment(
-
inspection,
-
assessment_key,
-
assessment_type,
-
passed,
-
is_incomplete
-
)
-
3584
fields = SeedData.send("#{assessment_type}_fields", passed: passed)
-
-
3584
then: 718
else: 2866
if assessment_key == :user_height_assessment && inspection.length && inspection.width
-
718
fields[:play_area_length] = inspection.length * 0.8
-
718
fields[:play_area_width] = inspection.width * 0.8
-
end
-
-
3584
fields = randomly_remove_fields(fields, is_incomplete)
-
3584
inspection.send(assessment_key).update!(fields)
-
end
-
-
5
sig { params(fields: T::Hash[Symbol, T.untyped], is_incomplete: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
-
4
def randomly_remove_fields(fields, is_incomplete)
-
3584
else: 386
then: 3198
return fields unless is_incomplete
-
386
else: 197
then: 189
return fields unless rand(0..1) == 0 # empty 50% of assessments
-
2919
fields.keys.each { |field| fields[field] = nil }
-
197
fields
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
4
class SentryTestService
-
4
def perform
-
results = []
-
-
# Test 1: Send a test message
-
begin
-
Sentry.capture_message("Test message from Rails app")
-
results << {test: "message", status: "success", message: "Test message sent to Sentry"}
-
rescue => e
-
results << {test: "message", status: "failed", message: "Failed to send test message: #{e.message}"}
-
end
-
-
# Test 2: Send a test exception
-
begin
-
1 / 0
-
rescue ZeroDivisionError => e
-
Sentry.capture_exception(e)
-
results << {test: "exception", status: "success", message: "Test exception sent to Sentry"}
-
end
-
-
# Test 3: Send exception with extra context
-
begin
-
Sentry.with_scope do |scope|
-
scope.set_context("test_info", {
-
source: "SentryTestService",
-
timestamp: Time.current.iso8601,
-
rails_env: Rails.env
-
})
-
scope.set_tags(test_type: "integration_test")
-
-
raise "This is a test error with context"
-
end
-
rescue => e
-
Sentry.capture_exception(e)
-
results << {test: "exception_with_context", status: "success", message: "Test exception with context sent to Sentry"}
-
end
-
-
# Return results and configuration info
-
{
-
results: results,
-
configuration: {
-
dsn_configured: Sentry.configuration.dsn.present?,
-
environment: Sentry.configuration.environment,
-
enabled_environments: Sentry.configuration.enabled_environments
-
}
-
}
-
end
-
-
4
def test_error_type(error_type)
-
case error_type
-
when :database_not_found
-
when: 0
# Simulate database not found error
-
Sentry.capture_message("Test: Database file not found", level: "error", extra: {
-
database_path: "/nonexistent/database.sqlite3",
-
test_type: "simulated_error"
-
})
-
when :missing_config
-
when: 0
# Simulate missing configuration error
-
Sentry.capture_message("Test: Missing S3 configuration", level: "error", extra: {
-
missing_vars: ["S3_ENDPOINT", "S3_BUCKET"],
-
test_type: "simulated_error"
-
})
-
when :generic_exception
-
when: 0
# Raise and capture a generic exception
-
begin
-
raise StandardError, "This is a test exception from SentryTestService"
-
rescue => e
-
Sentry.capture_exception(e, extra: {
-
test_type: "generic_exception",
-
source: "SentryTestService#test_error_type"
-
})
-
end
-
else: 0
else
-
raise ArgumentError, "Unknown error type: #{error_type}"
-
end
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class UnitCreationFromInspectionService
-
4
extend T::Sig
-
4
sig { returns(T::Array[String]) }
-
4
attr_reader :errors
-
-
6
sig { params(user: User, inspection_id: String, unit_params: ActionController::Parameters).void }
-
4
def initialize(user:, inspection_id:, unit_params:)
-
5
@user = user
-
5
@inspection_id = inspection_id
-
5
@unit_params = unit_params
-
5
@errors = []
-
end
-
-
6
sig { returns(T::Boolean) }
-
4
def create
-
5
else: 3
then: 2
return false unless validate_inspection
-
-
3
@unit = @user.units.build(@unit_params)
-
3
then: 2
if @unit.save
-
2
@inspection.update!(unit: @unit)
-
2
true
-
else: 1
else
-
1
false
-
end
-
end
-
-
6
sig { returns(T.nilable(Inspection)) }
-
4
attr_reader :inspection
-
-
6
sig { returns(T.nilable(Unit)) }
-
4
attr_reader :unit
-
-
5
sig { returns(T.nilable(String)) }
-
4
def error_message
-
5
@errors.first
-
end
-
-
4
private
-
-
6
sig { returns(T::Boolean) }
-
4
def validate_inspection
-
5
@inspection = @user.inspections.find_by(id: @inspection_id)
-
-
5
else: 4
then: 1
unless @inspection
-
1
@errors << I18n.t("units.errors.inspection_not_found")
-
1
return false
-
end
-
-
4
then: 1
else: 3
if @inspection.unit
-
1
@errors << I18n.t("units.errors.inspection_has_unit")
-
1
return false
-
end
-
-
3
true
-
end
-
end
-
# typed: true
-
# frozen_string_literal: true
-
-
4
class UnitCsvExportService
-
4
extend T::Sig
-
4
ATTRIBUTES = %w[id name manufacturer serial].freeze
-
-
5
sig { params(units: ActiveRecord::Relation).void }
-
4
def initialize(units)
-
2
@units = units
-
end
-
-
5
sig { returns(String) }
-
4
def generate
-
2
CSV.generate(headers: true) do |csv|
-
2
csv << ATTRIBUTES
-
-
2
@units.order(created_at: :desc).each do |unit|
-
5
csv << ATTRIBUTES.map { |attr| unit.send(attr) }
-
end
-
end
-
end
-
end
-
# Reusable code standards checker for both rake tasks and hooks
-
4
class CodeStandardsChecker
-
4
HARDCODED_STRINGS_ALLOWED_PATHS = %w[/lib/ /seeds/ /spec/ /test/].freeze
-
-
4
def initialize(max_method_lines: 20, max_file_lines: 500, max_line_length: 80)
-
31
@max_method_lines = max_method_lines
-
31
@max_file_lines = max_file_lines
-
31
@max_line_length = max_line_length
-
end
-
-
4
def check_file(file_path)
-
25
else: 20
then: 5
return [] unless File.exist?(file_path) && file_path.end_with?(".rb")
-
-
20
relative_path = file_path.sub(Rails.root.join("").to_s, "")
-
20
file_content = File.read(file_path)
-
20
file_lines = file_content.lines
-
-
20
violations = []
-
20
violations.concat(check_file_length(relative_path, file_lines))
-
20
violations.concat(check_line_lengths(relative_path, file_lines))
-
20
violations.concat(check_method_lengths(relative_path, file_path))
-
20
violations.concat(check_hardcoded_strings(relative_path, file_lines))
-
-
20
violations
-
end
-
-
4
def check_multiple_files(file_paths)
-
1
all_violations = []
-
1
file_paths.each do |file_path|
-
2
all_violations.concat(check_file(file_path))
-
end
-
1
all_violations
-
end
-
-
4
def format_violations(violations, show_summary: true)
-
5
then: 1
else: 4
return "✅ All files meet code standards!" if violations.empty?
-
-
4
output = []
-
20
violations_by_type = violations.group_by { |v| v[:type] }
-
-
4
output.concat(format_violations_by_type(violations_by_type))
-
4
then: 3
else: 1
output.concat(format_summary(violations)) if show_summary
-
-
4
output.join("\n")
-
end
-
-
4
private
-
-
4
def format_violations_by_type(violations_by_type)
-
4
output = []
-
4
violations_by_type.each do |type, type_violations|
-
16
type_name = type.to_s.upcase.tr("_", " ")
-
16
output << "#{type_name} VIOLATIONS (#{type_violations.length}):"
-
16
output << "-" * 50
-
-
16
type_violations.each do |violation|
-
16
line_ref = violation[:line_number] || ""
-
16
output << "#{violation[:file]}:#{line_ref} #{violation[:message]}"
-
end
-
16
output << ""
-
end
-
4
output
-
end
-
-
4
def format_summary(violations)
-
[
-
3
"=" * 80,
-
"TOTAL: #{violations.length} violations found"
-
]
-
end
-
-
4
def check_file_length(relative_path, file_lines)
-
20
else: 1
then: 19
return [] unless file_lines.length > @max_file_lines
-
-
[{
-
1
file: relative_path,
-
type: :file_length,
-
message: "#{file_lines.length} lines (max #{@max_file_lines})"
-
}]
-
end
-
-
4
def check_line_lengths(relative_path, file_lines)
-
20
violations = []
-
20
file_lines.each_with_index do |line, index|
-
1197
else: 4
then: 1193
next unless line.chomp.length > @max_line_length
-
-
4
violations << {
-
file: relative_path,
-
type: :line_length,
-
line_number: index + 1,
-
length: line.chomp.length,
-
message: build_line_length_message(index + 1, line.chomp.length)
-
}
-
end
-
20
violations
-
end
-
-
4
def check_method_lengths(relative_path, file_path)
-
20
methods = extract_methods_from_file(file_path)
-
41
long_methods = methods.select { |m| m[:length] > @max_method_lines }
-
-
20
long_methods.map do |method|
-
{
-
4
file: relative_path,
-
type: :method_length,
-
line_number: method[:start_line],
-
message: build_method_length_message(method)
-
}
-
end
-
end
-
-
4
def check_hardcoded_strings(relative_path, file_lines)
-
20
then: 0
else: 20
return [] if skip_hardcoded_strings?(relative_path)
-
-
20
violations = []
-
20
file_lines.each_with_index do |line, index|
-
1197
line_violations = check_line_for_hardcoded_strings(
-
relative_path, line, index + 1
-
)
-
1197
violations.concat(line_violations)
-
end
-
20
violations
-
end
-
-
4
def check_line_for_hardcoded_strings(relative_path, line, line_number)
-
1197
stripped = line.strip
-
1197
then: 1096
else: 101
return [] if should_skip_line?(stripped)
-
-
101
hardcoded_strings = extract_quoted_strings(stripped)
-
-
101
hardcoded_strings.filter_map do |string|
-
21
else: 10
then: 11
next unless should_flag_string?(string)
-
-
{
-
10
file: relative_path,
-
type: :hardcoded_string,
-
line_number: line_number,
-
message: build_hardcoded_string_message(line_number, string)
-
}
-
end
-
end
-
-
4
def skip_hardcoded_strings?(relative_path)
-
20
allowed_path = HARDCODED_STRINGS_ALLOWED_PATHS.any? do |path|
-
80
relative_path.include?(path)
-
end
-
20
allowed_path || relative_path.include?("seed_data_service.rb")
-
end
-
-
4
def should_skip_line?(stripped)
-
1197
stripped.start_with?("#") ||
-
stripped.match?(/\/.*\//) ||
-
stripped.include?("I18n.t") ||
-
stripped.include?("Rails.logger") ||
-
stripped.include?("puts") ||
-
stripped.include?("print")
-
end
-
-
4
def extract_quoted_strings(stripped)
-
101
strings = stripped.scan(/"([^"]*)"/).flatten
-
101
strings += stripped.scan(/'([^']*)'/).flatten
-
101
strings
-
end
-
-
4
def should_flag_string?(string)
-
21
else: 21
then: 0
return false unless string.match?(/\w/)
-
21
then: 9
else: 12
return false if technical_string?(string)
-
12
then: 0
else: 12
return false if string.length < 3
-
12
then: 2
else: 10
return false if string.match?(/^#\{.*\}$/)
-
-
10
string.match?(/[A-Z].*[a-z]/) || string.include?(" ")
-
end
-
-
4
def technical_string?(string)
-
21
string.match?(/^[a-z_]+$/) ||
-
string.match?(/^[A-Z_]+$/) ||
-
string.match?(/^[a-z]+\.[a-z]+/) ||
-
string.match?(/^\//) ||
-
string.match?(/^[a-z]+_[a-z]+_path$/) ||
-
string.match?(/^\w+:/)
-
end
-
-
4
def build_line_length_message(line_number, length)
-
4
"Line #{line_number}: #{length} chars (max #{@max_line_length})"
-
end
-
-
4
def build_method_length_message(method)
-
4
name = method[:name]
-
4
length = method[:length]
-
4
"Method '#{name}' is #{length} lines (max #{@max_method_lines})"
-
end
-
-
4
def build_hardcoded_string_message(line_number, string)
-
10
"Line #{line_number}: Hardcoded string '#{string}' - use I18n.t() instead"
-
end
-
-
4
def extract_methods_from_file(file_path)
-
20
content = File.read(file_path)
-
20
methods = []
-
20
parser_state = {current_method: nil, indent_level: 0, method_start_line: 0}
-
-
20
content.lines.each_with_index do |line, index|
-
1197
process_line_for_methods(
-
line,
-
index + 1,
-
methods,
-
parser_state,
-
file_path
-
)
-
end
-
-
20
finalize_last_method(methods, parser_state, content, file_path)
-
20
methods
-
end
-
-
4
def process_line_for_methods(line, line_number, methods, state, file_path)
-
1197
stripped = line.strip
-
-
1197
then: 21
if method_definition?(stripped)
-
21
save_current_method(methods, state, line_number, file_path)
-
21
else: 1176
start_new_method(stripped, line, line_number, state)
-
1176
else: 1157
elsif method_end?(
-
state[:current_method],
-
stripped,
-
line,
-
state[:indent_level]
-
then: 19
)
-
19
finish_current_method(methods, state, line_number, file_path)
-
end
-
end
-
-
4
def save_current_method(methods, state, line_number, file_path)
-
21
else: 0
then: 21
return unless state[:current_method]
-
-
add_method_to_list(methods, state, line_number, file_path)
-
end
-
-
4
def start_new_method(stripped, line, line_number, state)
-
21
state[:current_method] = extract_method_name(stripped)
-
21
state[:method_start_line] = line_number
-
21
state[:indent_level] = line.match(/^(\s*)/)[1].length
-
end
-
-
4
def finish_current_method(methods, state, line_number, file_path)
-
19
add_method_to_list(methods, state, line_number, file_path)
-
19
state[:current_method] = nil
-
end
-
-
4
def finalize_last_method(methods, state, content, file_path)
-
20
else: 2
then: 18
return unless state[:current_method]
-
-
2
end_line = content.lines.length
-
2
add_method_to_list(methods, state, end_line, file_path)
-
end
-
-
4
def add_method_to_list(methods, state, end_line, file_path)
-
21
methods << build_method_info(
-
state[:current_method], state[:method_start_line], end_line, file_path
-
)
-
end
-
-
4
def method_definition?(stripped)
-
1197
/^(private|protected|public\s+)?def\s+/.match?(stripped)
-
end
-
-
4
def method_end?(current_method, stripped, line, indent_level)
-
1176
else: 135
then: 1041
return false unless current_method && !stripped.empty?
-
-
135
current_indent = line.match(/^(\s*)/)[1].length
-
135
stripped == "end" && current_indent <= indent_level
-
end
-
-
4
def extract_method_name(stripped)
-
21
stripped.match(/def\s+([^\s\(]+)/)[1]
-
end
-
-
4
def build_method_info(method_name, start_line, end_line, file_path)
-
{
-
21
name: method_name,
-
start_line: start_line,
-
end_line: end_line,
-
length: end_line - start_line + 1,
-
file: file_path
-
}
-
end
-
-
4
def build_final_method_info(method_name, start_line, content, file_path)
-
end_line = content.lines.length
-
{
-
name: method_name,
-
start_line: start_line,
-
end_line: end_line,
-
length: end_line - start_line + 1,
-
file: file_path
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
4
require "open3"
-
-
# Runs erb_lint on files one at a time with progress output
-
# rubocop:disable Rails/Output
-
4
class ErbLintRunner
-
4
def initialize(autocorrect: false, verbose: false)
-
@autocorrect = autocorrect
-
@verbose = verbose
-
@processed = 0
-
@total_violations = 0
-
@failed_files = []
-
end
-
-
4
def run_on_all_files
-
erb_files = find_erb_files
-
puts "Found #{erb_files.length} ERB files to lint..."
-
puts "=" * 80
-
-
erb_files.each_with_index do |file, index|
-
process_file(file, index + 1, erb_files.length)
-
end
-
-
print_summary
-
@failed_files.empty?
-
end
-
-
4
def run_on_files(files)
-
puts "Linting #{files.length} ERB files..."
-
puts "=" * 80
-
-
files.each_with_index do |file, index|
-
process_file(file, index + 1, files.length)
-
end
-
-
print_summary
-
@failed_files.empty?
-
end
-
-
4
private
-
-
4
def find_erb_files
-
patterns = ["**/*.erb", "**/*.html.erb"]
-
exclude_dirs = ["vendor", "node_modules", "tmp", "public"]
-
-
files = []
-
patterns.each do |pattern|
-
Dir.glob(Rails.root.join(pattern).to_s).each do |file|
-
relative_path = file.sub(Rails.root.to_s + "/", "")
-
then: 0
else: 0
next if exclude_dirs.any? { |dir| relative_path.start_with?(dir) }
-
files << relative_path
-
end
-
end
-
-
files.uniq.sort
-
end
-
-
4
def process_file(file, current, total)
-
print "[#{current}/#{total}] #{file.ljust(60)} "
-
$stdout.flush
-
-
start_time = Time.now.to_f
-
-
# Use Open3 for safer command execution
-
cmd_args = ["bundle", "exec", "erb_lint", file]
-
then: 0
else: 0
cmd_args << "--autocorrect" if @autocorrect
-
-
output, status = Open3.capture2e(*cmd_args)
-
success = status.success?
-
elapsed = (Time.now.to_f - start_time).round(2)
-
-
then: 0
if success
-
puts "✅ (#{elapsed}s)"
-
else: 0
else
-
violations = extract_violation_count(output)
-
@total_violations += violations
-
@failed_files << {file:, violations:, output:}
-
-
# Show slow linter warning if it took too long
-
then: 0
if elapsed > 5.0
-
puts "❌ #{violations} violation(s) (#{elapsed}s) ⚠️ SLOW"
-
then: 0
else: 0
if @verbose
-
puts " Slow file details:"
-
puts output.lines.grep(/\A\s*\d+:\d+/).first(3).map { |line| " #{line.strip}" }
-
end
-
else: 0
else
-
puts "❌ #{violations} violation(s) (#{elapsed}s)"
-
end
-
end
-
-
@processed += 1
-
rescue => e
-
puts "💥 Error: #{e.message}"
-
@failed_files << {file:, violations: 0, output: e.message}
-
end
-
-
4
def extract_violation_count(output)
-
# erb_lint output format: "1 error(s) were found"
-
match = output.match(/(\d+) error\(s\) were found/)
-
then: 0
else: 0
match ? match[1].to_i : 1
-
end
-
-
4
def print_summary
-
puts "=" * 80
-
puts "\nSUMMARY:"
-
puts "Processed: #{@processed} files"
-
puts "Failed: #{@failed_files.length} files"
-
puts "Total violations: #{@total_violations}"
-
-
then: 0
if @failed_files.any?
-
puts "\nFailed files:"
-
@failed_files.each do |failure|
-
puts " #{failure[:file]} (#{failure[:violations]} violation(s))"
-
end
-
-
then: 0
else: 0
if !@autocorrect
-
puts "\nTo fix these issues, run:"
-
puts " rake code_standards:erb_lint_fix"
-
end
-
else: 0
else
-
puts "\n✅ All ERB files passed linting!"
-
end
-
end
-
end
-
# rubocop:enable Rails/Output
-
# Module to track I18n key usage during test runs
-
4
module I18nUsageTracker
-
4
class << self
-
4
attr_accessor :tracking_enabled
-
4
attr_reader :used_keys
-
-
4
def reset!
-
53
@used_keys = Set.new
-
53
@tracking_enabled = false
-
end
-
-
4
def track_key(key, options = {})
-
26
else: 24
then: 2
return unless tracking_enabled && key
-
-
# Handle both string and symbol keys
-
24
key_string = key.to_s
-
-
# Skip Rails internal keys and error keys
-
24
then: 8
else: 16
return if key_string.start_with?("errors.", "activerecord.", "activemodel.", "helpers.", "number.", "date.", "time.", "support.")
-
-
# Track the full key
-
16
@used_keys << key_string
-
-
# Also track parent keys for nested translations
-
# e.g., for "users.messages.created", also track "users.messages" and "users"
-
16
parts = key_string.split(".")
-
16
(1...parts.length).each do |i|
-
13
parent_key = parts[0...i].join(".")
-
13
else: 0
then: 13
@used_keys << parent_key unless parent_key.empty?
-
end
-
-
# Track keys used with scope option
-
16
then: 4
else: 12
if options[:scope]
-
4
scope = Array(options[:scope]).join(".")
-
4
full_key = "#{scope}.#{key_string}"
-
4
@used_keys << full_key
-
-
# Track parent keys for scoped translations
-
4
full_parts = full_key.split(".")
-
4
(1...full_parts.length).each do |i|
-
7
parent_key = full_parts[0...i].join(".")
-
7
else: 0
then: 7
@used_keys << parent_key unless parent_key.empty?
-
end
-
end
-
end
-
-
4
def all_locale_keys
-
9
@all_locale_keys ||= begin
-
1
keys = Set.new
-
-
# Load all locale files
-
1
locale_files = Rails.root.glob("config/locales/**/*.yml")
-
-
1
locale_files.each do |file|
-
44
yaml_content = YAML.load_file(file)
-
-
# Process each locale (en, es, etc.)
-
44
yaml_content.each do |locale, content|
-
44
extract_keys_from_hash(content, [], keys)
-
end
-
end
-
-
1
keys
-
end
-
end
-
-
4
def unused_keys
-
5
all_locale_keys - used_keys
-
end
-
-
4
def usage_report
-
2
total_keys = all_locale_keys.size
-
2
used_count = used_keys.size
-
2
unused_count = unused_keys.size
-
-
{
-
2
total_keys: total_keys,
-
used_keys: used_count,
-
unused_keys: unused_count,
-
2
usage_percentage: (used_count.to_f / total_keys * 100).round(2),
-
unused_key_list: unused_keys.sort
-
}
-
end
-
-
4
private
-
-
4
def extract_keys_from_hash(hash, current_path, keys)
-
402
hash.each do |key, value|
-
1768
new_path = current_path + [key.to_s]
-
1768
full_key = new_path.join(".")
-
-
1768
then: 356
if value.is_a?(Hash)
-
356
keys << full_key
-
356
extract_keys_from_hash(value, new_path, keys)
-
else: 1412
else
-
1412
keys << full_key
-
end
-
end
-
end
-
end
-
-
# Reset on initialization
-
4
reset!
-
-
# Add at_exit hook to save tracking results if enabled
-
4
at_exit do
-
4
then: 0
else: 4
if tracking_enabled && used_keys.any?
-
Rails.root.join("tmp/i18n_tracking_results.json").write(used_keys.to_a.to_json)
-
end
-
end
-
end
-
-
# Monkey patch I18n.t to track usage
-
4
module I18n
-
4
class << self
-
4
alias_method :original_t, :t
-
4
alias_method :original_translate, :translate
-
-
4
def t(key, **options)
-
48733
then: 2
else: 48731
I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
-
48733
original_t(key, **options)
-
end
-
-
4
def translate(key, **options)
-
54353
then: 1
else: 54352
I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
-
54353
original_translate(key, **options)
-
end
-
end
-
end
-
-
# Also track Rails view helpers
-
4
then: 4
else: 0
if defined?(ActionView::Helpers::TranslationHelper)
-
4
module ActionView::Helpers::TranslationHelper
-
4
alias_method :original_t, :t
-
4
alias_method :original_translate, :translate
-
-
4
def t(key, **options)
-
44740
then: 0
else: 44740
I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
-
44740
original_t(key, **options)
-
end
-
-
4
def translate(key, **options)
-
then: 0
else: 0
I18nUsageTracker.track_key(key, options) if I18nUsageTracker.tracking_enabled
-
original_translate(key, **options)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module RuboCop
-
1
module Cop
-
1
module Custom
-
# Enforces line breaks after ? and : in ternary operators when
-
# line exceeds 80 characters
-
#
-
# @example
-
# # bad (when line > 80 chars)
-
# result = condition == 2 ? long_true_value : long_false_value
-
#
-
# # good
-
# result = condition == 2 ?
-
# long_true_value :
-
# long_false_value
-
#
-
1
class TernaryLineBreaks < Base
-
1
extend AutoCorrector
-
-
1
MSG = "Break ternary operator across multiple lines " \
-
"when line exceeds 80 characters"
-
1
MAX_LINE_LENGTH = 80
-
-
1
def on_if(node)
-
16
else: 15
then: 1
return unless node.ternary?
-
15
else: 6
then: 9
return unless line_too_long?(node)
-
-
6
add_offense(node) do |corrector|
-
6
autocorrect(corrector, node)
-
end
-
end
-
-
1
private
-
-
1
def line_too_long?(node)
-
15
line = processed_source.lines[node.first_line - 1]
-
15
line.length > MAX_LINE_LENGTH
-
end
-
-
1
def autocorrect(corrector, node)
-
6
condition = node.condition
-
6
if_branch = node.if_branch
-
6
else_branch = node.else_branch
-
-
# Get the indentation of the line containing the ternary
-
6
indent = processed_source.lines[node.first_line - 1][/\A\s*/]
-
6
nested_indent = "#{indent} "
-
-
# Build the corrected version
-
6
corrected = "#{condition.source} ?\n"
-
6
corrected << "#{nested_indent}#{if_branch.source} :\n"
-
6
corrected << "#{nested_indent}#{else_branch.source}"
-
-
6
corrector.replace(node, corrected)
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
4
require_relative "../app/services/concerns/s3_backup_operations"
-
-
# Helper methods for S3 operations in rake tasks
-
# These wrap the S3Helpers module methods to work in rake context
-
4
module S3RakeHelpers
-
4
include S3BackupOperations
-
-
4
def ensure_s3_enabled
-
then: 0
else: 0
return if ENV["USE_S3_STORAGE"] == "true"
-
-
error_msg = "S3 storage is not enabled. Set USE_S3_STORAGE=true in your .env file"
-
Rails.logger.debug { "❌ #{error_msg}" }
-
raise StandardError, error_msg
-
end
-
-
4
def validate_s3_config
-
required_vars = %w[S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY S3_BUCKET]
-
missing_vars = required_vars.select { |var| ENV[var].blank? }
-
-
then: 0
else: 0
if missing_vars.any?
-
error_msg = "Missing required S3 environment variables: #{missing_vars.join(", ")}"
-
Rails.logger.debug { "❌ #{error_msg}" }
-
-
Sentry.capture_message(error_msg, level: "error", extra: {
-
missing_vars: missing_vars,
-
task: caller_locations(1, 1)[0].label,
-
environment: Rails.env
-
})
-
-
raise StandardError, error_msg
-
end
-
end
-
-
4
def get_s3_service
-
service = ActiveStorage::Blob.service
-
-
else: 0
then: 0
unless service.is_a?(ActiveStorage::Service::S3Service)
-
error_msg = "Active Storage is not configured to use S3. Current service: #{service.class.name}"
-
Rails.logger.debug { "❌ #{error_msg}" }
-
raise StandardError, error_msg
-
end
-
-
service
-
end
-
-
4
def handle_s3_errors
-
yield
-
rescue Aws::S3::Errors::ServiceError => e
-
Rails.logger.debug { "\n❌ S3 Error: #{e.message}" }
-
Sentry.capture_exception(e)
-
raise
-
rescue => e
-
Rails.logger.debug { "\n❌ Unexpected error: #{e.message}" }
-
Sentry.capture_exception(e)
-
raise
-
end
-
end
-
# frozen_string_literal: true
-
-
# This module provides field mappings for assessments
-
# Used by both seeds and tests to ensure consistency
-
4
module SeedData
-
4
def self.check_passed?(inspection_passed)
-
18438
then: 16254
else: 2184
return true if inspection_passed
-
-
2184
rand < 0.9
-
end
-
-
4
def self.check_passed_integer?(inspection_passed)
-
5460
then: 4818
else: 642
return :pass if inspection_passed
-
-
642
then: 593
else: 49
(rand < 0.9) ? :pass : :fail
-
end
-
-
4
def self.user_fields
-
{
-
17
email: "test#{SecureRandom.hex(8)}@example.com",
-
password: "password123",
-
password_confirmation: "password123",
-
name: "Test User #{SecureRandom.hex(4)}",
-
rpii_inspector_number: nil # Optional field
-
}
-
end
-
-
4
def self.unit_fields
-
{
-
10
name: "Bouncy Castle #{%w[Mega Super Fun Party Adventure].sample} #{SecureRandom.hex(4)}",
-
serial: "BC-#{Date.current.year}-#{SecureRandom.hex(4).upcase}",
-
manufacturer: ["ABC Inflatables", "XYZ Bounce Co", "Fun Factory", "Party Products Ltd"].sample,
-
operator: ["Rental Company #{SecureRandom.hex(2)}", "Party Hire #{SecureRandom.hex(2)}", "Events Ltd #{SecureRandom.hex(2)}"].sample,
-
manufacture_date: Date.current - rand(365..1825).days,
-
description: "Commercial grade inflatable bouncy castle suitable for events"
-
}
-
end
-
-
4
def self.inspection_fields(passed: true)
-
{
-
15
inspection_date: Date.current,
-
is_totally_enclosed: [true, false].sample,
-
has_slide: [true, false].sample,
-
indoor_only: [true, false].sample,
-
width: rand(4.0..8.0).round(1),
-
length: rand(5.0..10.0).round(1),
-
height: rand(3.0..6.0).round(1)
-
}
-
end
-
-
4
def self.results_fields(passed: true)
-
{
-
4
passed: passed,
-
risk_assessment: "Low risk - all safety features functional and tested"
-
}
-
end
-
-
4
def self.anchorage_fields(passed: true)
-
fields = {
-
368
num_low_anchors: rand(6..12),
-
num_high_anchors: rand(4..8),
-
num_low_anchors_pass: check_passed?(passed),
-
num_high_anchors_pass: check_passed?(passed),
-
anchor_accessories_pass: check_passed?(passed),
-
anchor_degree_pass: check_passed?(passed),
-
anchor_type_pass: check_passed?(passed),
-
pull_strength_pass: check_passed?(passed)
-
}
-
-
368
else: 326
then: 42
fields[:anchor_type_comment] = "Some wear visible on anchor points" unless passed
-
-
368
fields
-
end
-
-
4
def self.structure_fields(passed: true)
-
{
-
734
seam_integrity_pass: check_passed?(passed),
-
air_loss_pass: check_passed?(passed),
-
straight_walls_pass: check_passed?(passed),
-
sharp_edges_pass: check_passed?(passed),
-
unit_stable_pass: check_passed?(passed),
-
stitch_length_pass: check_passed?(passed),
-
step_ramp_size_pass: check_passed?(passed),
-
platform_height_pass: check_passed?(passed),
-
critical_fall_off_height_pass: check_passed?(passed),
-
unit_pressure_pass: check_passed?(passed),
-
trough_pass: check_passed?(passed),
-
entrapment_pass: check_passed?(passed),
-
markings_pass: check_passed?(passed),
-
grounding_pass: check_passed?(passed),
-
unit_pressure: rand(1.0..3.0).round(1),
-
step_ramp_size: rand(200..400),
-
platform_height: rand(500..1500),
-
critical_fall_off_height: rand(500..2000),
-
trough_depth: rand(30..80),
-
trough_adjacent_panel_width: rand(300..1000),
-
evacuation_time_pass: check_passed?(passed),
-
734
then: 648
seam_integrity_comment: if passed
-
648
"All seams in good condition"
-
else: 86
else
-
86
"Minor thread loosening noted"
-
end,
-
stitch_length_comment: "Measured at regular intervals",
-
platform_height_comment: "Platform height acceptable for age group"
-
}
-
end
-
-
4
def self.materials_fields(passed: true)
-
fields = {
-
742
ropes: rand(18..45),
-
ropes_pass: check_passed_integer?(passed),
-
retention_netting_pass: check_passed_integer?(passed),
-
zips_pass: check_passed_integer?(passed),
-
windows_pass: check_passed_integer?(passed),
-
artwork_pass: check_passed_integer?(passed),
-
thread_pass: check_passed?(passed),
-
fabric_strength_pass: check_passed?(passed),
-
fire_retardant_pass: check_passed?(passed)
-
}
-
-
742
then: 657
if passed
-
657
fields[:fabric_strength_comment] = "Fabric in good condition"
-
else: 85
else
-
85
fields[:ropes_comment] = "Rope shows signs of wear"
-
85
fields[:fabric_strength_comment] = "Minor surface wear noted"
-
end
-
-
742
fields
-
end
-
-
4
def self.fan_fields(passed: true)
-
{
-
734
blower_flap_pass: check_passed_integer?(passed),
-
blower_finger_pass: check_passed?(passed),
-
blower_visual_pass: check_passed?(passed),
-
pat_pass: check_passed_integer?(passed),
-
blower_serial: "FAN-#{SecureRandom.hex(6).upcase}",
-
number_of_blowers: 1,
-
blower_tube_length: rand(2.0..5.0).round(1),
-
blower_tube_length_pass: check_passed?(passed),
-
734
then: 649
fan_size_type: if passed
-
649
"Fan operating correctly at optimal pressure"
-
else: 85
else
-
85
"Fan requires servicing"
-
end,
-
734
then: 649
blower_flap_comment: if passed
-
649
"Flap mechanism functioning correctly"
-
else: 85
else
-
85
"Flap sticking occasionally"
-
end,
-
734
then: 649
blower_finger_comment: if passed
-
649
"Guard secure, no finger trap hazards"
-
else: 85
else
-
85
"Guard needs tightening"
-
end,
-
734
then: 649
blower_visual_comment: if passed
-
649
"Visual inspection satisfactory"
-
else: 85
else
-
85
"Some wear visible on housing"
-
end,
-
734
then: 649
pat_comment: if passed
-
649
"PAT test valid until #{(Date.current + 6.months).strftime("%B %Y")}"
-
else: 85
else
-
85
"PAT test overdue"
-
end
-
}
-
end
-
-
4
def self.user_height_fields(passed: true)
-
{
-
733
containing_wall_height: rand(1.0..2.0).round(1),
-
users_at_1000mm: rand(0..5),
-
users_at_1200mm: rand(2..8),
-
users_at_1500mm: rand(4..10),
-
users_at_1800mm: rand(2..6),
-
custom_user_height_comment: "Sample custom height comments",
-
play_area_length: rand(3.0..10.0).round(1),
-
play_area_width: rand(3.0..8.0).round(1),
-
negative_adjustment: rand(0..2.0).round(1),
-
containing_wall_height_comment: "Measured from base to top of wall",
-
play_area_length_comment: "Effective play area after deducting obstacles",
-
play_area_width_comment: "Width measured at narrowest point"
-
}
-
end
-
-
4
def self.slide_fields(passed: true)
-
282
platform_height = rand(2.0..6.0).round(1)
-
-
# Use the actual SafetyStandard calculation for consistency
-
282
required_runout = EN14960.calculate_slide_runout(platform_height).value
-
-
282
then: 235
runout = if passed
-
235
(required_runout + rand(0.5..1.5)).round(1)
-
else: 47
else
-
47
fail_margin = rand(0.1..0.3)
-
47
(required_runout - fail_margin)
-
end
-
-
{
-
282
slide_platform_height: platform_height,
-
slide_wall_height: rand(1.0..2.0).round(1),
-
runout: runout,
-
slide_first_metre_height: rand(0.3..0.8).round(1),
-
slide_beyond_first_metre_height: rand(0.8..1.5).round(1),
-
clamber_netting_pass: check_passed_integer?(passed),
-
runout_pass: check_passed?(passed),
-
slip_sheet_pass: check_passed?(passed),
-
slide_permanent_roof: false,
-
282
then: 235
slide_platform_height_comment: if passed
-
235
"Platform height compliant with EN 14960:2019"
-
else: 47
else
-
47
"Platform height exceeds recommended limits"
-
end,
-
slide_wall_height_comment: "Wall height measured from slide bed",
-
282
then: 235
runout_comment: if passed
-
235
"Runout area clear and adequate"
-
else: 47
else
-
47
"Runout area needs extending"
-
end,
-
282
then: 235
clamber_netting_comment: if passed
-
235
"Netting secure with no gaps"
-
else: 47
else
-
47
"Some gaps in netting need attention"
-
end,
-
282
then: 235
slip_sheet_comment: if passed
-
235
"Slip sheet in good condition"
-
else: 47
else
-
47
"Slip sheet showing wear"
-
end
-
}
-
end
-
-
4
def self.enclosed_fields(passed: true)
-
{
-
114
exit_number: rand(1..3),
-
exit_number_pass: check_passed?(passed),
-
exit_sign_always_visible_pass: check_passed?(passed),
-
114
then: 95
exit_number_comment: if passed
-
95
"Number of exits compliant with unit size"
-
else: 19
else
-
19
"Additional exit required"
-
end,
-
114
then: 95
exit_sign_always_visible_comment: if passed
-
95
"Exit signs visible from all points"
-
else: 19
else
-
19
"Exit signs obscured from some angles"
-
end
-
}
-
end
-
end
-
# typed: false
-
-
# Shared test data helpers for generating realistic British data
-
# Used by both factories and seeds for non-critical test data generation
-
4
module TestDataHelpers
-
# Generate realistic UK mobile numbers (07xxx xxx xxx - 11 digits)
-
4
def self.british_phone_number
-
22
"07#{rand(100..999)} #{rand(100..999)} #{rand(100..999)}"
-
end
-
-
# Generate realistic UK postcodes
-
4
def self.british_postcode
-
22
prefixes = %w[SW SE NW N E W EC WC B M L G EH CF BS OX CB]
-
22
prefix = prefixes.sample
-
22
letters = ("A".."Z").to_a
-
22
"#{prefix}#{rand(1..20)} #{rand(1..9)}#{letters.sample}#{letters.sample}"
-
end
-
-
# Generate realistic UK street addresses
-
4
def self.british_address
-
streets = %w[
-
22
High\ Street
-
Church\ Lane
-
Victoria\ Road
-
King's\ Road
-
Queen\ Street
-
Park\ Avenue
-
Station\ Road
-
London\ Road
-
Market\ Square
-
The\ Green
-
]
-
22
numbers = (1..200).to_a
-
22
"#{numbers.sample} #{streets.sample}"
-
end
-
-
# Common British cities
-
BRITISH_CITIES = %w[
-
4
London
-
Birmingham
-
Manchester
-
Leeds
-
Liverpool
-
Newcastle
-
Bristol
-
Sheffield
-
Nottingham
-
Leicester
-
Oxford
-
Cambridge
-
Brighton
-
Southampton
-
Edinburgh
-
Glasgow
-
Cardiff
-
Belfast
-
].freeze
-
-
4
def self.british_city
-
28
BRITISH_CITIES.sample
-
end
-
-
# Generate a British company name variation
-
4
def self.british_company_name(base_name)
-
15
suffixes = ["Ltd", "UK", "Services", "Solutions", "Group", "& Co"]
-
15
"#{base_name} #{suffixes.sample}"
-
end
-
end