From 733d236503d7a82de74406378a97e79c69007cfc Mon Sep 17 00:00:00 2001 From: Katya Sarmiento <5871075+Kitkatnik@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:56:23 -0400 Subject: [PATCH 01/17] Replace Embassy mock data with real backend - Add Question, NotaryProfile, EmbassyBooking, EmbassyApplication, and EmbassyApplicationAnswer models, with embassy_mode/embassy_capacity columns on ScheduleItem. - Seed 80 questions and 18 notaries from db/seeds/embassy_questions.rb via idempotent EmbassyQuestionsSeed.import! (keyed by external_id). - Wire EmbassyBookings, EmbassyApplications, and the Admin::* controllers to real ActiveRecord queries in place of FakeEmbassy. - Generate real PDF downloads with Prawn (PassportApplicationPdf, formal tax-form aesthetic), replacing the browser-print HTML partial. - Draw Section 3 questions and notary by least-used-first (EmbassyApplicationDraw) for fair distribution across applicants. - Enforce booking capacity inside a row-locked transaction; support edit-while-draft on EmbassyApplication. - Add Stimulus controller to toggle the embassy capacity/mode fields on the admin schedule-item form when kind = embassy. - Delete fake_embassy.rb, _pdf.html.erb, _expired.html.erb. --- Gemfile | 4 + Gemfile.lock | 9 + .../admin/embassy_applications_controller.rb | 39 +- .../admin/embassy_blank_pdfs_controller.rb | 13 +- .../admin/embassy_questions_controller.rb | 54 +- .../admin/schedule_items_controller.rb | 10 +- .../embassy_applications_controller.rb | 119 +++- .../embassy_bookings_controller.rb | 50 +- app/helpers/embassy_questions_helper.rb | 63 +++ .../embassy_settings_controller.js | 17 + app/models/embassy_application.rb | 33 ++ app/models/embassy_application_answer.rb | 18 + app/models/embassy_booking.rb | 18 + app/models/notary_profile.rb | 12 + app/models/question.rb | 24 + app/models/schedule_item.rb | 18 + app/models/user.rb | 2 + app/services/embassy_application_draw.rb | 25 + .../embassy_application_serial_generator.rb | 14 + app/services/fake_embassy.rb | 382 ------------- app/services/passport_application_pdf.rb | 301 ++++++++++ .../admin/embassy_applications/index.html.erb | 64 ++- .../admin/embassy_applications/show.html.erb | 79 ++- .../admin/embassy_blank_pdfs/create.html.erb | 45 -- .../admin/embassy_blank_pdfs/new.html.erb | 29 +- .../admin/embassy_questions/_form.html.erb | 94 ++-- .../embassy_questions/_question_row.html.erb | 38 +- .../admin/embassy_questions/edit.html.erb | 9 +- .../admin/embassy_questions/index.html.erb | 115 ++-- .../admin/embassy_questions/new.html.erb | 5 +- app/views/admin/schedule_items/_form.html.erb | 60 +- .../_appointment_card.html.erb | 8 +- .../embassy_applications/_expired.html.erb | 18 - app/views/embassy_applications/_pdf.html.erb | 179 ------ .../embassy_applications/_question.html.erb | 80 +-- .../embassy_applications/_section.html.erb | 17 +- app/views/embassy_applications/new.html.erb | 58 +- app/views/embassy_applications/show.html.erb | 31 +- app/views/embassy_bookings/_confirm.html.erb | 13 +- app/views/embassy_bookings/create.html.erb | 4 +- app/views/embassy_bookings/new.html.erb | 8 +- app/views/plan/_plan_item.html.erb | 60 +- app/views/schedule/_session_item.html.erb | 22 +- ..._add_embassy_settings_to_schedule_items.rb | 8 + db/migrate/20260425220219_create_questions.rb | 22 + .../20260425220222_create_notary_profiles.rb | 14 + .../20260425220223_create_embassy_bookings.rb | 15 + ...60425220225_create_embassy_applications.rb | 17 + ...0226_create_embassy_application_answers.rb | 17 + ...0425220442_add_placeholder_to_questions.rb | 5 + db/schema.rb | 81 ++- db/seeds.rb | 7 + db/seeds/embassy_questions.rb | 531 +++++++----------- test/fixtures/embassy_application_answers.yml | 13 + test/fixtures/embassy_applications.yml | 17 + test/fixtures/embassy_bookings.yml | 15 + test/fixtures/notary_profiles.yml | 13 + test/fixtures/questions.yml | 25 + .../models/embassy_application_answer_test.rb | 7 + test/models/embassy_application_test.rb | 7 + test/models/embassy_booking_test.rb | 7 + test/models/notary_profile_test.rb | 7 + test/models/question_test.rb | 7 + 63 files changed, 1706 insertions(+), 1390 deletions(-) create mode 100644 app/helpers/embassy_questions_helper.rb create mode 100644 app/javascript/controllers/embassy_settings_controller.js create mode 100644 app/models/embassy_application.rb create mode 100644 app/models/embassy_application_answer.rb create mode 100644 app/models/embassy_booking.rb create mode 100644 app/models/notary_profile.rb create mode 100644 app/models/question.rb create mode 100644 app/services/embassy_application_draw.rb create mode 100644 app/services/embassy_application_serial_generator.rb delete mode 100644 app/services/fake_embassy.rb create mode 100644 app/services/passport_application_pdf.rb delete mode 100644 app/views/admin/embassy_blank_pdfs/create.html.erb delete mode 100644 app/views/embassy_applications/_expired.html.erb delete mode 100644 app/views/embassy_applications/_pdf.html.erb create mode 100644 db/migrate/20260425220216_add_embassy_settings_to_schedule_items.rb create mode 100644 db/migrate/20260425220219_create_questions.rb create mode 100644 db/migrate/20260425220222_create_notary_profiles.rb create mode 100644 db/migrate/20260425220223_create_embassy_bookings.rb create mode 100644 db/migrate/20260425220225_create_embassy_applications.rb create mode 100644 db/migrate/20260425220226_create_embassy_application_answers.rb create mode 100644 db/migrate/20260425220442_add_placeholder_to_questions.rb create mode 100644 test/fixtures/embassy_application_answers.yml create mode 100644 test/fixtures/embassy_applications.yml create mode 100644 test/fixtures/embassy_bookings.yml create mode 100644 test/fixtures/notary_profiles.yml create mode 100644 test/fixtures/questions.yml create mode 100644 test/models/embassy_application_answer_test.rb create mode 100644 test/models/embassy_application_test.rb create mode 100644 test/models/embassy_booking_test.rb create mode 100644 test/models/notary_profile_test.rb create mode 100644 test/models/question_test.rb diff --git a/Gemfile b/Gemfile index ff07466..8c5fd94 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,10 @@ gem "tito_ruby" # Postmark for transactional email in production gem "postmark-rails" +# PDF generation for the passport application form +gem "prawn" +gem "prawn-table" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index b08612d..56885f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -243,6 +243,7 @@ GEM parser (3.3.11.1) ast (~> 2.4.1) racc + pdf-core (0.9.0) pg (1.6.3) pg (1.6.3-aarch64-linux) pg (1.6.3-aarch64-linux-musl) @@ -257,6 +258,11 @@ GEM postmark (>= 1.21.3, < 2.0) pp (0.6.3) prettyprint + prawn (2.4.0) + pdf-core (~> 0.9.0) + ttfunk (~> 1.7) + prawn-table (0.2.2) + prawn (>= 1.3.0, < 3.0.0) prettyprint (0.2.0) prism (1.9.0) propshaft (1.3.1) @@ -398,6 +404,7 @@ GEM activemodel (>= 7.1, < 9.0) faraday (~> 2.0) tsort (0.2.0) + ttfunk (1.7.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -449,6 +456,8 @@ DEPENDENCIES mission_control-jobs pg (~> 1.1) postmark-rails + prawn + prawn-table propshaft puma (>= 5.0) rack-mini-profiler diff --git a/app/controllers/admin/embassy_applications_controller.rb b/app/controllers/admin/embassy_applications_controller.rb index d641d95..f112c2b 100644 --- a/app/controllers/admin/embassy_applications_controller.rb +++ b/app/controllers/admin/embassy_applications_controller.rb @@ -1,10 +1,43 @@ class Admin::EmbassyApplicationsController < AdminController def index - @applications = FakeEmbassy.submitted_applications + @applications = EmbassyApplication + .submitted + .includes(:notary_profile, embassy_booking: [:user, :schedule_item]) + .order(submitted_at: :desc) end def show - @application = FakeEmbassy.find_submitted_application(params[:id]) - @sections = FakeEmbassy.sample_questions + @application = EmbassyApplication.includes( + :notary_profile, + embassy_application_answers: :question, + embassy_booking: [:user, :schedule_item] + ).find_by!(serial: params[:id]) + + @booking = @application.embassy_booking + @schedule_item = @application.schedule_item + @notary = @application.notary_profile + @sections = sections_for(@application) + + respond_to do |format| + format.html + format.pdf do + send_data PassportApplicationPdf.new(application: @application).render, + filename: "#{@application.serial}.pdf", + type: "application/pdf", + disposition: "attachment" + end + end + end + + private + + def sections_for(application) + { + 1 => Question.for_section(1).to_a, + 2 => Question.for_section(2).to_a, + 3 => application.drawn_questions.to_a, + 4 => Question.for_section(4).to_a, + 5 => Question.for_section(5).to_a + } end end diff --git a/app/controllers/admin/embassy_blank_pdfs_controller.rb b/app/controllers/admin/embassy_blank_pdfs_controller.rb index 58fe02b..0b023d3 100644 --- a/app/controllers/admin/embassy_blank_pdfs_controller.rb +++ b/app/controllers/admin/embassy_blank_pdfs_controller.rb @@ -1,13 +1,14 @@ class Admin::EmbassyBlankPdfsController < AdminController def new - @default_count = 12 - @preview_sections = FakeEmbassy.sample_questions - @preview_serial = "RE-0427-A" - @preview_notary = EmbassyQuestionsSeed::NOTARY_POOL.sample(random: Random.new(42)) + @default_count = 12 end def create - @count = (params[:count].presence || 12).to_i.clamp(1, 100) - @serials = (0...@count).map { |i| FakeEmbassy.serial_for(i) } + count = (params[:count].presence || 12).to_i.clamp(1, 100) + + send_data PassportApplicationPdf.new(application: nil, count: count).render, + filename: "ruby-embassy-blank-applications-#{count}.pdf", + type: "application/pdf", + disposition: "attachment" end end diff --git a/app/controllers/admin/embassy_questions_controller.rb b/app/controllers/admin/embassy_questions_controller.rb index 4eb4e3b..d846fd9 100644 --- a/app/controllers/admin/embassy_questions_controller.rb +++ b/app/controllers/admin/embassy_questions_controller.rb @@ -1,30 +1,66 @@ class Admin::EmbassyQuestionsController < AdminController + before_action :set_question, only: %i[edit update destroy] + def index - @questions = FakeEmbassy.question_bank - @notary_pool = FakeEmbassy.notary_pool + @questions = Question + .left_joins(:embassy_application_answers) + .group("questions.id") + .select("questions.*, COUNT(DISTINCT embassy_application_answers.embassy_application_id) AS computed_usage_count") + .order(:section, :position) + + @questions_by_section = @questions.group_by(&:section) + @notary_pool = NotaryProfile.order(:external_id) end def new - @question = { id: "", section: 1, label: "", type: :short, - required: false, help: "", status: "active" } + @question = Question.new(section: 1, field_type: "short", scope: "common", required: false, status: "active", options: []) end def create - redirect_to admin_embassy_questions_path, - notice: "Question added to the bank." + @question = Question.new(question_params) + if @question.save + redirect_to admin_embassy_questions_path, + notice: "Question added to the bank." + else + render :new, status: :unprocessable_content + end end def edit - @question = FakeEmbassy.find_question(params[:id]) end def update - redirect_to admin_embassy_questions_path, - notice: "Question updated." + if @question.update(question_params) + redirect_to admin_embassy_questions_path, + notice: "Question updated." + else + render :edit, status: :unprocessable_content + end end def destroy + @question.update!(status: "archived") redirect_to admin_embassy_questions_path, notice: "Question archived." end + + private + + def set_question + @question = Question.find(params[:id]) + end + + def question_params + permitted = params.require(:question).permit( + :external_id, :section, :position, :label, :help, :placeholder, + :field_type, :required, :scope, :status, :options_text + ) + parse_options(permitted) + end + + def parse_options(permitted) + text = permitted.delete(:options_text) + permitted[:options] = text.to_s.split(/\r?\n/).map(&:strip).reject(&:blank?) if text + permitted + end end diff --git a/app/controllers/admin/schedule_items_controller.rb b/app/controllers/admin/schedule_items_controller.rb index 02e6ff0..c98e108 100644 --- a/app/controllers/admin/schedule_items_controller.rb +++ b/app/controllers/admin/schedule_items_controller.rb @@ -45,10 +45,16 @@ def set_schedule_item # :slug is intentionally omitted — it's a seed idempotency key for # config/schedule.yml items and should never be set by hand. def schedule_item_params - params.require(:schedule_item).permit( + attrs = params.require(:schedule_item).permit( :day, :time_label, :sort_time, :title, :host, - :location, :description, :kind, :flexible, :is_public + :location, :description, :kind, :flexible, :is_public, + :embassy_mode, :embassy_capacity ) + unless attrs[:kind] == "embassy" + attrs[:embassy_mode] = nil + attrs[:embassy_capacity] = nil + end + attrs end end end diff --git a/app/controllers/embassy_applications_controller.rb b/app/controllers/embassy_applications_controller.rb index aa9ce53..17a1f90 100644 --- a/app/controllers/embassy_applications_controller.rb +++ b/app/controllers/embassy_applications_controller.rb @@ -1,31 +1,120 @@ class EmbassyApplicationsController < ApplicationController + before_action :set_application_for_show, only: %i[show edit update] + def new - @plan_item = current_user.plan_items.find_by(id: params[:plan_item_id]) - @schedule_item = @plan_item&.schedule_item - @sections = FakeEmbassy.sample_questions - @serial = FakeEmbassy.serial_for(@plan_item&.id || 1) - @minutes_left = FakeEmbassy.reservation_minutes_left(@plan_item&.id || 1) + @booking = current_user.embassy_bookings.find(params[:embassy_booking_id]) + unless @booking.application_required? + redirect_to plan_path, + notice: "Stamping appointments don't require an application." and return + end + + @application = @booking.embassy_application || @booking.create_embassy_application! + EmbassyApplicationDraw.call(@application) + + if @application.submitted? + redirect_to embassy_application_path(@application) and return + end + + @sections = sections_for(@application) + @schedule_item = @booking.schedule_item end def create - plan_item_id = params[:plan_item_id] || 1 - redirect_to embassy_application_path(FakeEmbassy.serial_for(plan_item_id)) + @booking = current_user.embassy_bookings.find(params[:embassy_booking_id]) + @application = @booking.embassy_application || @booking.create_embassy_application! + EmbassyApplicationDraw.call(@application) + + save_answers!(@application, params[:q] || {}) + @application.update!(state: "submitted", submitted_at: Time.current) + + redirect_to embassy_application_path(@application), + notice: "Application submitted. Your serial is #{@application.serial}." end def show - @serial = params[:id] - @sections = FakeEmbassy.sample_questions - @schedule_item = ScheduleItem.embassy.first || ScheduleItem.first - @plan_item = current_user.plan_items.joins(:schedule_item) - .where(schedule_items: { kind: ScheduleItem.kinds[:embassy] }) - .first + respond_to do |format| + format.html do + @sections = sections_for(@application) + @schedule_item = @application.schedule_item + @booking = @application.embassy_booking + @notary = @application.notary_profile + end + format.pdf do + send_data PassportApplicationPdf.new(application: @application).render, + filename: "#{@application.serial}.pdf", + type: "application/pdf", + disposition: "attachment" + end + end end def edit - redirect_to new_embassy_application_path(plan_item_id: params[:id]) + if @application.submitted? + redirect_to embassy_application_path(@application), + notice: "This application has already been submitted." and return + end + @booking = @application.embassy_booking + @sections = sections_for(@application) + @schedule_item = @booking.schedule_item + render :new end def update - redirect_to embassy_application_path(params[:id]) + if @application.submitted? + redirect_to embassy_application_path(@application) and return + end + + save_answers!(@application, params[:q] || {}) + + if params[:submit] == "1" + @application.update!(state: "submitted", submitted_at: Time.current) + redirect_to embassy_application_path(@application), + notice: "Application submitted. Your serial is #{@application.serial}." + else + redirect_to edit_embassy_application_path(@application), + notice: "Draft saved." + end + end + + private + + def set_application_for_show + @application = EmbassyApplication.find_by!(serial: params[:id]) + unless admin? || @application.user == current_user + raise ActiveRecord::RecordNotFound + end + end + + def sections_for(application) + { + 1 => Question.active.for_section(1).to_a, + 2 => Question.active.for_section(2).to_a, + 3 => application.drawn_questions.to_a, + 4 => Question.active.for_section(4).to_a, + 5 => Question.active.for_section(5).to_a + } + end + + def save_answers!(application, q_params) + questions_by_external_id = Question.where(external_id: q_params.keys).index_by(&:external_id) + + EmbassyApplicationAnswer.transaction do + q_params.each do |external_id, raw_value| + question = questions_by_external_id[external_id] + next unless question + + answer = application.embassy_application_answers.find_or_initialize_by(question: question) + + if question.field_type == "checkbox_group" + answer.value_array = Array(raw_value).reject(&:blank?) + answer.value_text = nil + else + answer.value_text = raw_value.to_s + answer.value_array = [] + end + + answer.save! + end + end end end diff --git a/app/controllers/embassy_bookings_controller.rb b/app/controllers/embassy_bookings_controller.rb index 2494454..c2369cc 100644 --- a/app/controllers/embassy_bookings_controller.rb +++ b/app/controllers/embassy_bookings_controller.rb @@ -1,22 +1,54 @@ class EmbassyBookingsController < ApplicationController def new - @schedule_item = ScheduleItem.find(params[:schedule_item_id]) - @block_mode = FakeEmbassy.mode_for(@schedule_item.id) + @schedule_item = ScheduleItem.embassy.find(params[:schedule_item_id]) + @block_mode = @schedule_item.embassy_mode || "new_passport" @chosen_mode = params[:mode].presence || (@block_mode == "both" ? nil : @block_mode) - @capacity = FakeEmbassy.capacity_for(@schedule_item.id) - @seats_taken = FakeEmbassy.seats_taken_for(@schedule_item.id) + @capacity = @schedule_item.embassy_capacity + @seats_taken = @schedule_item.seats_taken + @existing_booking = current_user.embassy_bookings.find_by(schedule_item: @schedule_item) end def create - @schedule_item = ScheduleItem.find(params[:schedule_item_id]) - mode = params[:mode].presence || "new_passport" - @plan_item = current_user.plan_items.find_or_create_by!(schedule_item: @schedule_item) + @schedule_item = ScheduleItem.embassy.find(params[:schedule_item_id]) + mode = resolved_mode - if mode == "stamping" + @booking = ActiveRecord::Base.transaction do + ScheduleItem.lock.find(@schedule_item.id) + + if @schedule_item.embassy_capacity.present? && @schedule_item.seats_taken >= @schedule_item.embassy_capacity + existing = current_user.embassy_bookings.find_by(schedule_item: @schedule_item) + next existing if existing + raise ActiveRecord::Rollback, :full + end + + plan_item = current_user.plan_items.find_or_create_by!(schedule_item: @schedule_item) + booking = EmbassyBooking.find_or_initialize_by(user: current_user, schedule_item: @schedule_item) + booking.plan_item = plan_item + booking.mode = mode + booking.state = "confirmed" + booking.save! + booking + end + + if @booking.nil? + redirect_to new_embassy_booking_path(schedule_item_id: @schedule_item.id), + alert: "This embassy block is full." + return + end + + if @booking.stamping? @chosen_mode = "stamping" render :create else - redirect_to new_embassy_application_path(plan_item_id: @plan_item.id) + redirect_to new_embassy_application_path(embassy_booking_id: @booking.id) end end + + private + + def resolved_mode + requested = params[:mode].presence + return requested if EmbassyBooking.modes.key?(requested) + @schedule_item.embassy_mode == "stamping" ? "stamping" : "new_passport" + end end diff --git a/app/helpers/embassy_questions_helper.rb b/app/helpers/embassy_questions_helper.rb new file mode 100644 index 0000000..0316ecb --- /dev/null +++ b/app/helpers/embassy_questions_helper.rb @@ -0,0 +1,63 @@ +module EmbassyQuestionsHelper + MODE_LABELS = { + "new_passport" => "New passport", + "stamping" => "Stamping", + "both" => "New passport or Stamping" + }.freeze + + MODE_SHORT = { + "new_passport" => "New passport", + "stamping" => "Stamping", + "both" => "New or Stamp" + }.freeze + + EMBASSY_ETIQUETTE = [ + "Please arrive 5 minutes before your appointment time.", + "Bring your printed application. Unprinted applications will be filled out on-site — paper only, please.", + "Stamping appointments require an existing Ruby passport. No passport, no stamp.", + "Photography inside the Embassy Office is permitted, but do not photograph the Stamping Apparatus.", + "The Embassy Office is located in the Main Hall. Look for the ruby-red awning." + ].freeze + + EMBASSY_HOURS = [ + "Saturday, May 2 · 1:00 PM – 5:00 PM", + "Sunday, May 3 · 9:00 AM – 12:00 PM (stamping only)" + ].freeze + + SECTION_META = { + 1 => { title: "Declaration of Ruby-ness", + instructions: "This section establishes the Applicant's eligibility for Passport issuance pursuant to Embassy Ordinance §1.9. Complete all required fields." }, + 2 => { title: "Statement of Intent & Character", + instructions: "The Applicant is required to disclose, in their own words, certain particulars of their programming disposition. Literal or metaphorical responses both accepted. Do not leave blank." }, + 3 => { title: "Supplementary Declarations", + instructions: "The following declarations are required under Embassy Ordinance §3.14. Answers are filed in perpetuity and may be referenced at any future Embassy proceeding." }, + 4 => { title: "Attestation of Community Standing", + instructions: "Each of the following is deemed material to the Embassy's assessment of community fitness under Ordinance §4.1. The Applicant is asked to affirm or decline without reservation." }, + 5 => { title: "Affidavit of Attendance", + instructions: "Falsified statements may result in revocation of Ruby Embassy privileges for up to three (3) business gems. The Applicant signs below under penalty of Rubocop." } + }.freeze + + def embassy_section_title(number) + SECTION_META.dig(number, :title) + end + + def embassy_section_instructions(number) + SECTION_META.dig(number, :instructions) + end + + def embassy_mode_label(mode) + MODE_LABELS[mode.to_s] || "—" + end + + def embassy_mode_short(mode) + MODE_SHORT[mode.to_s] || "—" + end + + def embassy_etiquette + EMBASSY_ETIQUETTE + end + + def embassy_hours + EMBASSY_HOURS + end +end diff --git a/app/javascript/controllers/embassy_settings_controller.js b/app/javascript/controllers/embassy_settings_controller.js new file mode 100644 index 0000000..f2a7f62 --- /dev/null +++ b/app/javascript/controllers/embassy_settings_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +// Toggles visibility of the embassy-only settings (capacity, mode) on the +// admin schedule-item form based on the kind dropdown. +export default class extends Controller { + static targets = ["kindSelect", "extras"] + + connect() { + this.toggle() + } + + toggle() { + if (!this.hasExtrasTarget || !this.hasKindSelectTarget) return + const isEmbassy = this.kindSelectTarget.value === "embassy" + this.extrasTarget.hidden = !isEmbassy + } +} diff --git a/app/models/embassy_application.rb b/app/models/embassy_application.rb new file mode 100644 index 0000000..7ef1ef9 --- /dev/null +++ b/app/models/embassy_application.rb @@ -0,0 +1,33 @@ +class EmbassyApplication < ApplicationRecord + belongs_to :embassy_booking + belongs_to :notary_profile, optional: true + has_many :embassy_application_answers, dependent: :destroy + has_one :user, through: :embassy_booking + has_one :schedule_item, through: :embassy_booking + + enum :state, { draft: "draft", submitted: "submitted" } + + validates :serial, presence: true, uniqueness: true + + before_validation :assign_serial, on: :create + + def to_param = serial + + def drawn_questions + Question.where(external_id: drawn_question_ids).ordered + end + + def common_questions(section) + Question.common.active.for_section(section) + end + + def answer_for(question) + embassy_application_answers.find_by(question_id: question.id) + end + + private + + def assign_serial + self.serial ||= EmbassyApplicationSerialGenerator.next + end +end diff --git a/app/models/embassy_application_answer.rb b/app/models/embassy_application_answer.rb new file mode 100644 index 0000000..9a551a4 --- /dev/null +++ b/app/models/embassy_application_answer.rb @@ -0,0 +1,18 @@ +class EmbassyApplicationAnswer < ApplicationRecord + belongs_to :embassy_application + belongs_to :question + + validates :question_id, uniqueness: { scope: :embassy_application_id } + + def display_value + case question.field_type + when "checkbox_group" then value_array + when "checkbox" then value_text == "true" + else value_text + end + end + + def blank_value? + value_text.blank? && value_array.blank? + end +end diff --git a/app/models/embassy_booking.rb b/app/models/embassy_booking.rb new file mode 100644 index 0000000..c10468f --- /dev/null +++ b/app/models/embassy_booking.rb @@ -0,0 +1,18 @@ +class EmbassyBooking < ApplicationRecord + belongs_to :user + belongs_to :schedule_item + belongs_to :plan_item + has_one :embassy_application, dependent: :destroy + + enum :mode, { new_passport: "new_passport", stamping: "stamping" } + enum :state, { confirmed: "confirmed", cancelled: "cancelled" } + + validates :mode, presence: true + validates :user_id, uniqueness: { scope: :schedule_item_id } + + scope :active, -> { confirmed } + + def application_required? + new_passport? + end +end diff --git a/app/models/notary_profile.rb b/app/models/notary_profile.rb new file mode 100644 index 0000000..266696b --- /dev/null +++ b/app/models/notary_profile.rb @@ -0,0 +1,12 @@ +class NotaryProfile < ApplicationRecord + has_many :embassy_applications, dependent: :nullify + + enum :status, { active: "active", archived: "archived" } + + validates :external_id, presence: true, uniqueness: true + validates :description, presence: true + + def usage_count + embassy_applications.where(state: "submitted").count + end +end diff --git a/app/models/question.rb b/app/models/question.rb new file mode 100644 index 0000000..7f5bd7b --- /dev/null +++ b/app/models/question.rb @@ -0,0 +1,24 @@ +class Question < ApplicationRecord + has_many :embassy_application_answers, dependent: :restrict_with_error + + enum :field_type, { + short: "short", long: "long", select: "select", + checkbox: "checkbox", checkbox_group: "checkbox_group", date: "date" + }, prefix: :field_type + enum :scope, { common: "common", random_pool: "random_pool" } + enum :status, { active: "active", archived: "archived" } + + validates :external_id, presence: true, uniqueness: true + validates :section, :label, :field_type, :scope, :status, presence: true + + scope :for_section, ->(n) { where(section: n).order(:position) } + scope :random_pool_active, -> { random_pool.active } + scope :ordered, -> { order(:section, :position) } + + def usage_count + embassy_application_answers + .joins(:embassy_application) + .where(embassy_applications: { state: "submitted" }) + .distinct.count(:embassy_application_id) + end +end diff --git a/app/models/schedule_item.rb b/app/models/schedule_item.rb index 72922b5..101077c 100644 --- a/app/models/schedule_item.rb +++ b/app/models/schedule_item.rb @@ -1,13 +1,18 @@ class ScheduleItem < ApplicationRecord + EMBASSY_MODES = %w[new_passport stamping both].freeze + belongs_to :created_by, class_name: "User", optional: true has_many :plan_items, dependent: :destroy has_many :attendees, through: :plan_items, source: :user + has_many :embassy_bookings, dependent: :destroy enum :kind, { talk: 0, lightning: 1, embassy: 2, activity: 3 } validates :title, presence: true validates :day, presence: true validates :kind, presence: true + validates :embassy_mode, inclusion: { in: EMBASSY_MODES }, allow_nil: true + validates :embassy_capacity, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true DAY_META = { "wed" => { label: "Wednesday", date: "April 29", subtitle: "Pre-Conference" }, @@ -42,6 +47,19 @@ def rsvp_count plan_items.count end + def seats_taken + embassy_bookings.active.count + end + + def seats_remaining + return nil unless embassy_capacity + [embassy_capacity - seats_taken, 0].max + end + + def full? + embassy_capacity.present? && seats_remaining.zero? + end + def editable_by?(user) return false if user.nil? user.admin? || created_by_id == user.id diff --git a/app/models/user.rb b/app/models/user.rb index c6a461e..7fe4bff 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,8 @@ class User < ApplicationRecord has_many :plan_items, dependent: :destroy has_many :planned_schedule_items, through: :plan_items, source: :schedule_item + has_many :embassy_bookings, dependent: :destroy + has_many :embassy_applications, through: :embassy_bookings has_many :created_schedule_items, class_name: "ScheduleItem", foreign_key: :created_by_id, diff --git a/app/services/embassy_application_draw.rb b/app/services/embassy_application_draw.rb new file mode 100644 index 0000000..9a1bbe3 --- /dev/null +++ b/app/services/embassy_application_draw.rb @@ -0,0 +1,25 @@ +class EmbassyApplicationDraw + POOL_SIZE = 4 + + def self.call(application) + return application if application.drawn_question_ids.present? + + drawn_ids = Question + .random_pool_active + .left_joins(:embassy_application_answers) + .group("questions.id") + .order(Arel.sql("COUNT(embassy_application_answers.id) ASC, RANDOM()")) + .limit(POOL_SIZE) + .pluck(:external_id) + + notary = NotaryProfile + .active + .left_joins(:embassy_applications) + .group("notary_profiles.id") + .order(Arel.sql("COUNT(embassy_applications.id) ASC, RANDOM()")) + .first + + application.update!(drawn_question_ids: drawn_ids, notary_profile: notary) + application + end +end diff --git a/app/services/embassy_application_serial_generator.rb b/app/services/embassy_application_serial_generator.rb new file mode 100644 index 0000000..3177055 --- /dev/null +++ b/app/services/embassy_application_serial_generator.rb @@ -0,0 +1,14 @@ +class EmbassyApplicationSerialGenerator + def self.next + today = Date.current + prefix = "RE-#{today.strftime('%m%d')}-" + count = EmbassyApplication.where("serial LIKE ?", "#{prefix}%").count + "#{prefix}#{letter_for(count)}" + end + + def self.letter_for(n) + alphabet = ("A".."Z").to_a + return alphabet[n] if n < 26 + "#{alphabet[(n / 26) - 1]}#{alphabet[n % 26]}" + end +end diff --git a/app/services/fake_embassy.rb b/app/services/fake_embassy.rb deleted file mode 100644 index fd82615..0000000 --- a/app/services/fake_embassy.rb +++ /dev/null @@ -1,382 +0,0 @@ -require Rails.root.join("db/seeds/embassy_questions").to_s - -module FakeEmbassy - module_function - - MODES = %w[new_passport stamping both].freeze - - def mode_for(schedule_item_id) - MODES[schedule_item_id.to_i % MODES.length] - end - - def mode_label(mode) - { - "new_passport" => "New passport", - "stamping" => "Stamping", - "both" => "New passport or Stamping" - }[mode] || "—" - end - - def mode_short(mode) - { "new_passport" => "New passport", "stamping" => "Stamping", "both" => "New or Stamp" }[mode] - end - - def capacity_for(schedule_item_id) - [ 6, 8, 10, 12 ][schedule_item_id.to_i % 4] - end - - def seats_taken_for(schedule_item_id) - [ 2, 3, 5, 7 ][schedule_item_id.to_i % 4].clamp(0, capacity_for(schedule_item_id)) - end - - def seats_remaining(schedule_item_id) - capacity_for(schedule_item_id) - seats_taken_for(schedule_item_id) - end - - def full?(schedule_item_id) - seats_remaining(schedule_item_id) <= 0 - end - - STATES = %w[stamping pending submitted expired].freeze - - def appointment_state_for(plan_item_id) - STATES[plan_item_id.to_i % STATES.length] - end - - def serial_for(id) - letter = ("A".."Z").to_a[id.to_i % 26] - "RE-0427-#{letter}" - end - - def reservation_minutes_left(plan_item_id) - [ 47, 32, 18, 3 ][plan_item_id.to_i % 4] - end - - # Deterministic draw shown on the default (Katya's) mockup preview. - DEFAULT_DRAWN_POOL_IDS = %w[3a01 3b05 3c04 3e03 3h03].freeze - DEFAULT_NOTARY_ID = "N01".freeze - - def sample_questions(drawn_ids: DEFAULT_DRAWN_POOL_IDS) - [ - { - number: 1, title: "Declaration of Ruby-ness", - scope: "common", - instructions: "This section establishes the Applicant's eligibility for Passport issuance pursuant to Embassy Ordinance §1.9. Complete all required fields.", - questions: EmbassyQuestionsSeed::BASIC_INFO - }, - { - number: 2, title: "Statement of Intent & Character", - scope: "common", - instructions: "The Applicant is required to disclose, in their own words, certain particulars of their programming disposition. Literal or metaphorical responses both accepted. Do not leave blank.", - questions: EmbassyQuestionsSeed::PERSONAL_STATEMENT - }, - { - # NOTE: title + instructions here are deliberately neutral — users - # must not know this section is randomized. Admin surfaces expose - # the scope/draws metadata. - number: 3, title: "Supplementary Declarations", - scope: "random_pool", - instructions: "The following declarations are required under Embassy Ordinance §3.14. Answers are filed in perpetuity and may be referenced at any future Embassy proceeding.", - draws: EmbassyQuestionsSeed::RANDOM_POOL_DRAWS, - pool_size: EmbassyQuestionsSeed::RANDOM_POOL.length, - questions: EmbassyQuestionsSeed::RANDOM_POOL.select { |q| drawn_ids.include?(q[:id]) } - }, - { - number: 4, title: "Attestation of Community Standing", - scope: "common", - instructions: "Each of the following is deemed material to the Embassy's assessment of community fitness under Ordinance §4.1. The Applicant is asked to affirm or decline without reservation.", - questions: EmbassyQuestionsSeed::COMMUNITY_ALIGNMENT - }, - { - number: 5, title: "Affidavit of Attendance", - scope: "common", - instructions: "Falsified statements may result in revocation of Ruby Embassy privileges for up to three (3) business gems. The Applicant signs below under penalty of Rubocop.", - questions: EmbassyQuestionsSeed::DECLARATION - } - ] - end - - # Returns sections customized for a specific submitted application so - # the admin sees the exact draw that attendee received. - def sections_for(application) - drawn = application[:drawn_ids] || DEFAULT_DRAWN_POOL_IDS - sample_questions(drawn_ids: drawn) - end - - # Notary drawn for a given application (or the default for the preview). - def notary_for(application = nil) - target_id = (application && application[:notary_id]) || DEFAULT_NOTARY_ID - EmbassyQuestionsSeed::NOTARY_POOL.find { |n| n[:id] == target_id } || - EmbassyQuestionsSeed::NOTARY_POOL.first - end - - FILLED_ANSWERS = { - # Section 1 · Declaration of Ruby-ness -------------------------------- - "1a" => "Katya", - "1b" => "@kitkatnik", - "1c" => "she/her", - "1d" => "9 years of Ruby, 14 years of existential dread", - "1e" => "I use Ruby", - "1f" => "Go", - "1g" => [ "Networking", "Vibes", "Free coffee" ], - "1h" => [ "Powerful", "Confused", "Like quitting forever" ], - "1i" => [ "The Debugger", "The \"it works, don't touch it\"" ], - - # Section 2 · Personal Statement -------------------------------------- - "2a" => "Programming and I have been together long enough that we finish each other's error messages.", - "2b" => "2.7–3.0", - "2c" => "1.8.7 — the heart wants what it wants", - "2d" => "We've never spoken, but I feel his presence every time I write a block. Once, while writing a method_missing, I felt we achieved mutual understanding.", - - # Section 3 · Supplementary Declarations (answers for every pool member, - # so any drawn subset renders correctly on any submitted application view) - "3a01" => "I once spent six hours debugging a production issue caused by a stray space in a CSV.", - "3a02" => "A migration that silently dropped 12% of user records. It 'passed review.'", - "3a03" => "The time I moved a file up two directories and 40 tests suddenly passed.", - "3a04" => "A method_missing handler that recursed through ActiveRecord looking for 'something that resembles a category.'", - "3a05" => "Regex. The answer is always regex.", - "3a06" => "I forgot to save the file for two hours.", - "3a07" => "Someone put a string comparison inside a callback that runs on every page load.", - - "3b01" => "If your test suite takes more than 90 seconds, it is a todo list, not a test suite.", - "3b02" => "I never run `bin/rails console --sandbox`. I live dangerously.", - "3b03" => "TDD. There, I said it.", - "3b04" => "Monorepos. They're fine. Stop fighting about this.", - "3b05" => "Misunderstood genius, but only for the first two years.", - "3b06" => "Situationship. It keeps calling me at 2am with production issues.", - "3b07" => "CoffeeScript. It is a haunted language and we all know it.", - - "3c01" => "It would tolerate me, the way a cat tolerates a roommate who pays the rent.", - "3c02" => "Rookie of the year, 2013. Did not win a match but showed up to all of them.", - "3c03" => "Passive-aggressive. Writes emails that are technically polite.", - "3c04" => "`tap`. Sneaky and elegant.", - "3c05" => "My past self would be horrified. We would not be speaking by the end of it.", - "3c06" => "Because the incident report writes itself.", - "3c07" => "I would simply start using Sinatra apps and eating dinner outside.", - - "3d01" => "A GeoCities page that would not load a rotating star GIF. I swore revenge.", - "3d02" => "The brief moment after a bug is fixed and before the next one appears.", - "3d03" => "A three-day Heisenbug that turned out to be a renamed column.", - "3d04" => "Knitting. It teaches you to unpick mistakes without crying.", - "3d05" => "Dark mode. Ambient music. A windowless room. No humans within 100 feet.", - "3d06" => "Walk. Shower. Complain to the duck on my desk. Return.", - - "3e01" => "Cottagecore with a vengeance.", - "3e02" => "Somewhere between 'I got this' and 'what is a kernel'.", - "3e03" => "A Heisenbug. You think you've seen me, then doubt it.", - "3e04" => "The distracted-boyfriend meme, but it's me looking at a new framework.", - "3e05" => "1 of 10. The rest are Stack Overflow tabs.", - "3e06" => "Three. Always three. I never learn.", - - "3f01" => "A community cookbook where every recipe comes with the grandmother's handwriting.", - "3f02" => "A habit tracker. I was not sufficiently in the habit of maintaining it.", - "3f03" => "A CLI that tells you whether your commit message is too funny for the branch.", - "3f04" => "A Mastodon bot that posts fake Ruby Weekly editions.", - "3f05" => "An entire second career in basket weaving.", - - "3g01" => "Yes. The project was 37% done. I said 'this weekend.'", - "3g02" => "10pm. Docker. Three caffeinated beverages. Sincere apology to my keyboard.", - "3g03" => "Fixed a race condition. Broke every feature flag.", - "3g04" => "I have nodded gravely through entire standups.", - "3g05" => "`legacy/billing_tax_legacy.rb`. Last touched 2014. Still running.", - "3g06" => "A cron job that periodically restarts a daemon. Nobody has ever fixed it.", - - "3h01" => "To remember why I liked Ruby in the first place.", - "3h02" => "To meet one person whose blog I've been reading since 2017.", - "3h03" => "Stand near the coffee. Wear sunglasses until 9am. Assume every hallway is a shortcut.", - "3h04" => "Four.", - "3h05" => "Avoid responsibilities, but enthusiastically.", - - "3i01" => "Pray. Monitor. Consider turning off the internet.", - "3i02" => "Check env vars. Check secrets. Blame the cache. Find the typo.", - "3i03" => "Find the oldest test file. Read it aloud. Decide if I can live here.", - "3i04" => "'Works on my machine' is the first half of a really useful sentence.", - "3i05" => "Open the editor. Stare. Close the editor. Rest.", - - "3j01" => "4–6.", - "3j02" => "Minitest.", - "3j03" => "Rolled a migration, rolled the wrong direction, rolled my chair into a wall.", - "3j04" => "We upgraded 4.2 to 5.0. We upgraded 5.0 to 5.1. We stopped upgrading.", - "3j05" => "A warm amaro, drunk slowly on a porch.", - "3j06" => "Yes. Transcendent, then regretful, then transcendent again.", - "3j07" => "The one that shall not be named.", - "3j08" => "Pair programming is the one time my keyboard is not mine.", - - # Section 4 · Community Alignment ------------------------------------- - "4a" => [ - "I believe debugging builds character", - "I have questioned my life choices while coding", - "I have said \"this should work\" (it did not)", - "I have fixed something and broken something else", - "I have Googled the same error more than once" - ], - - # Section 5 · Declaration --------------------------------------------- - "5a" => true, - "5b" => "2026-04-29", - "5c" => "I plead the fifth", - "5d" => "Katya Sarmiento" - }.freeze - - def filled_answer(question_id) - FILLED_ANSWERS[question_id] - end - - def submitted_applications - [ - { serial: "RE-0427-A", attendee_name: "Katya Sarmiento", attendee_email: "software@adhdcoder.com", - slot: "Sat May 2 · 2:00 PM", submitted_at: "Apr 30 · 3:14 PM", - drawn_ids: %w[3a01 3b05 3c04 3e03 3h03], notary_id: "N01" }, - { serial: "RE-0427-B", attendee_name: "John Athayde", attendee_email: "john@example.com", - slot: "Sat May 2 · 2:00 PM", submitted_at: "Apr 30 · 4:02 PM", - drawn_ids: %w[3a04 3b02 3c06 3d01 3g02], notary_id: "N05" }, - { serial: "RE-0427-C", attendee_name: "Aaron Patterson", attendee_email: "aaron@example.com", - slot: "Sat May 2 · 2:15 PM", submitted_at: "May 1 · 9:11 AM", - drawn_ids: %w[3a02 3b07 3c07 3d03 3h01], notary_id: "N13" }, - { serial: "RE-0427-D", attendee_name: "Sandi Metz", attendee_email: "sandi@example.com", - slot: "Sat May 2 · 2:15 PM", submitted_at: "May 1 · 10:48 AM", - drawn_ids: %w[3a05 3b06 3c01 3f01 3g04], notary_id: "N02" }, - { serial: "RE-0427-E", attendee_name: "Avdi Grimm", attendee_email: "avdi@example.com", - slot: "Sat May 2 · 2:30 PM", submitted_at: "May 1 · 11:02 AM", - drawn_ids: %w[3a07 3b03 3c05 3d06 3i02], notary_id: "N09" }, - { serial: "RE-0427-F", attendee_name: "Mislav Marohnić", attendee_email: "mislav@example.com", - slot: "Sat May 2 · 2:30 PM", submitted_at: "May 1 · 12:19 PM", - drawn_ids: %w[3a03 3b04 3c03 3e01 3i05], notary_id: "N15" }, - { serial: "RE-0427-G", attendee_name: "Penelope Phippen", attendee_email: "penelope@example.com", - slot: "Sat May 2 · 2:45 PM", submitted_at: "May 1 · 1:45 PM", - drawn_ids: %w[3a06 3b01 3c02 3f03 3h02], notary_id: "N08" } - ] - end - - def find_submitted_application(serial) - submitted_applications.find { |a| a[:serial] == serial } || - { serial: serial, attendee_name: "Unknown", attendee_email: "—", - slot: "—", submitted_at: "—", - drawn_ids: DEFAULT_DRAWN_POOL_IDS.dup, notary_id: DEFAULT_NOTARY_ID } - end - - # === Admin: flat question bank for the Question Bank index ============== - def question_bank - bank = [] - - EmbassyQuestionsSeed::BASIC_INFO.each do |q| - bank << q.merge( - section: 1, section_title: "Declaration of Ruby-ness", - section_scope: "common", status: "active", - usage_count: deterministic_usage(q[:id]) - ) - end - - EmbassyQuestionsSeed::PERSONAL_STATEMENT.each do |q| - bank << q.merge( - section: 2, section_title: "Statement of Intent & Character", - section_scope: "common", status: "active", - usage_count: deterministic_usage(q[:id]) - ) - end - - EmbassyQuestionsSeed::RANDOM_POOL_CATEGORIES.each do |key, category| - category[:questions].each do |q| - bank << q.merge( - section: 3, section_title: "Supplementary Declarations", - section_scope: "random_pool", - category: key, category_title: category[:title], - status: "active", - usage_count: deterministic_usage(q[:id]) - ) - end - end - - EmbassyQuestionsSeed::COMMUNITY_ALIGNMENT.each do |q| - bank << q.merge( - section: 4, section_title: "Attestation of Community Standing", - section_scope: "common", status: "active", - usage_count: deterministic_usage(q[:id]) - ) - end - - EmbassyQuestionsSeed::DECLARATION.each do |q| - bank << q.merge( - section: 5, section_title: "Affidavit of Attendance", - section_scope: "common", status: "active", - usage_count: deterministic_usage(q[:id]) - ) - end - - bank << { - id: "X1", section: 0, section_title: "Archived", - section_scope: "common", - label: "Preferred blend of Ruby Roast coffee (retired question)", - type: :select, required: false, usage_count: 12, status: "archived" - } - - bank - end - - # Notary pool — shown in its own admin block below the question table. - def notary_pool - EmbassyQuestionsSeed::NOTARY_POOL.map do |n| - n.merge( - followup_count: n[:followups].length, - usage_count: deterministic_usage(n[:id]), - status: "active" - ) - end - end - - # Per-section metadata for admin question-bank index headers. - SECTION_META = { - 1 => { title: "Declaration of Ruby-ness", scope: "common", - hint: "Identity, proficiency, declared disposition. Every applicant fills these out." }, - 2 => { title: "Statement of Intent & Character", scope: "common", - hint: "Free-form personal statement. Every applicant fills these out." }, - 3 => { title: "Supplementary Declarations", scope: "random_pool", - hint: "Internally a random pool — draws %{draws} of %{total} per application. Users see only a numbered section.", - draws: EmbassyQuestionsSeed::RANDOM_POOL_DRAWS }, - 4 => { title: "Attestation of Community Standing", scope: "common", - hint: "Single checkbox group. Every applicant affirms the applicable statements." }, - 5 => { title: "Affidavit of Attendance", scope: "common", - hint: "Signature, arrival date, final attestations. Every applicant fills these out." } - }.freeze - - def section_meta - SECTION_META - end - - def random_pool_total - EmbassyQuestionsSeed::RANDOM_POOL.length - end - - def random_pool_draws - EmbassyQuestionsSeed::RANDOM_POOL_DRAWS - end - - def random_pool_categories - EmbassyQuestionsSeed::RANDOM_POOL_CATEGORIES - end - - def find_question(question_id) - question_bank.find { |q| q[:id] == question_id } || question_bank.first - end - - def embassy_etiquette - [ - "Please arrive 5 minutes before your appointment time.", - "Bring your printed application. Unprinted applications will be filled out on-site — paper only, please.", - "Stamping appointments require an existing Ruby passport. No passport, no stamp.", - "Photography inside the Embassy Office is permitted, but do not photograph the Stamping Apparatus.", - "The Embassy Office is located in the Main Hall. Look for the ruby-red awning." - ] - end - - def embassy_hours - [ - "Saturday, May 2 · 1:00 PM – 5:00 PM", - "Sunday, May 3 · 9:00 AM – 12:00 PM (stamping only)" - ] - end - - def deterministic_usage(id) - 40 + (id.to_s.bytes.sum % 25) - end -end diff --git a/app/services/passport_application_pdf.rb b/app/services/passport_application_pdf.rb new file mode 100644 index 0000000..dbd0f56 --- /dev/null +++ b/app/services/passport_application_pdf.rb @@ -0,0 +1,301 @@ +require "prawn" + +Prawn::Fonts::AFM.hide_m17n_warning = true + +# Builds the official Ruby Embassy passport application PDF. +# +# Aesthetic: as formal as a US tax form. No logo. Serif type, fine rules, +# OMB-style form code, single-column dense layout, "FOR OFFICIAL EMBASSY +# USE ONLY" stamp area. +# +# Renders 2 pages per application: +# 1. Sections 1–5 of the application + applicant signature line +# 2. Notary certification with description, single follow-up, sig lines +# +# Pass `application: nil` plus `count: N` to render `N` blank applications +# in a single document (used by the admin blank-pdf generator). +class PassportApplicationPdf + FORM_CODE = "Form RE-1 (Rev. 2026-04)".freeze + PAGE_SIZE = "LETTER".freeze + MARGIN = 54 # 0.75 inch + + def initialize(application: nil, count: 1) + @application = application + @count = count + end + + def render + pdf = Prawn::Document.new(page_size: PAGE_SIZE, margin: MARGIN) + pdf.font_families.update( + "Times" => { + normal: "Times-Roman", bold: "Times-Bold", + italic: "Times-Italic", bold_italic: "Times-BoldItalic" + } + ) + pdf.font "Times" + pdf.default_leading 1 + + if @application + render_application(pdf, @application) + else + @count.times do |i| + render_application(pdf, nil) + pdf.start_new_page if i < @count - 1 + end + end + + pdf.render + end + + private + + def render_application(pdf, application) + @questions_index = build_questions_index + render_page_one(pdf, application) + pdf.start_new_page + render_page_two(pdf, application) + end + + # ------------------------------------------------------------------ Page 1 + + def render_page_one(pdf, application) + render_header(pdf, application, page_label: "Page 1 of 2") + + pdf.move_down 8 + render_section(pdf, application, 1, "DECLARATION OF RUBY-NESS") + render_section(pdf, application, 2, "STATEMENT OF INTENT & CHARACTER") + render_section(pdf, application, 3, "SUPPLEMENTARY DECLARATIONS", drawn: true) + render_section(pdf, application, 4, "ATTESTATION OF COMMUNITY STANDING") + render_section(pdf, application, 5, "AFFIDAVIT OF ATTENDANCE") + render_signature_block(pdf, application) + + render_footer(pdf, application, page_label: "Page 1 of 2") + end + + # ------------------------------------------------------------------ Page 2 + + def render_page_two(pdf, application) + render_header(pdf, application, page_label: "Page 2 of 2", title: "NOTARY CERTIFICATION ADDENDUM") + + pdf.move_down 12 + pdf.text "PART A — NOTARY DESIGNATION", size: 9, style: :bold + pdf.stroke_horizontal_rule + pdf.move_down 6 + + pdf.text <<~PREAMBLE.strip, size: 9, leading: 2 + The Applicant must locate, in person and on Embassy premises, an attendee whose + circumstances match the description set forth in Box A.1 below. The Applicant + shall present this form to said attendee, who shall thereafter act as Notary + and complete Part B in the Applicant's presence. + PREAMBLE + + pdf.move_down 10 + pdf.text "A.1 The Notary must be an attendee who:", size: 9, style: :bold + pdf.move_down 4 + notary = application&.notary_profile + boxed_text(pdf, notary&.description, height: 36) + + pdf.move_down 14 + pdf.text "PART B — NOTARY ATTESTATION", size: 9, style: :bold + pdf.stroke_horizontal_rule + pdf.move_down 6 + + pdf.text "B.1 Notary's response to the following inquiry:", size: 9, style: :bold + pdf.move_down 2 + pdf.text notary&.followup_prompt.to_s, size: 9, style: :italic + pdf.move_down 4 + boxed_text(pdf, nil, height: 60) + + pdf.move_down 14 + pdf.text "B.2 Certification by the Notary", size: 9, style: :bold + pdf.move_down 4 + pdf.text "I certify that the Applicant has appeared before me and has answered " \ + "the inquiry set forth in B.1 truthfully and to the best of their ability.", + size: 9, leading: 2 + + pdf.move_down 18 + sig_field(pdf, "Notary's printed name", width: 240) + pdf.move_up 28 + pdf.bounding_box([260, pdf.cursor + 28], width: 220) do + sig_field(pdf, "Date", width: 220) + end + + pdf.move_down 8 + sig_field(pdf, "Notary's signature", width: 240) + + pdf.move_down 18 + pdf.text "FOR OFFICIAL EMBASSY USE ONLY", size: 8, style: :bold, align: :center + pdf.stroke_rectangle [pdf.bounds.left, pdf.cursor - 6], pdf.bounds.width, 60 + pdf.move_down 70 + + render_footer(pdf, application, page_label: "Page 2 of 2") + end + + # ---------------------------------------------------------------- Sections + + def render_section(pdf, application, section_number, title, drawn: false) + pdf.move_down 10 + pdf.text "SECTION #{section_number} — #{title}", size: 10, style: :bold + pdf.stroke_horizontal_rule + pdf.move_down 6 + + questions = section_questions(application, section_number, drawn: drawn) + return if questions.empty? + + questions.each_with_index do |question, idx| + render_question(pdf, application, question, "#{section_number}.#{idx + 1}") + end + end + + def render_question(pdf, application, question, item_label) + answer = application&.answer_for(question) + + pdf.move_down 6 + pdf.text "#{item_label} #{question.label}", size: 9, style: :bold + pdf.text question.help, size: 8, style: :italic, color: "555555" if question.help.present? + + pdf.move_down 4 + + case question.field_type + when "short", "select", "date" + boxed_text(pdf, answer&.display_value.to_s, height: 18) + when "long" + boxed_text(pdf, answer&.display_value.to_s, height: 48) + when "checkbox" + checked = answer&.display_value == true + pdf.formatted_text [ + { text: checked ? "[X] " : "[ ] ", styles: [:bold] }, + { text: "I affirm.", size: 9 } + ] + when "checkbox_group" + selected = Array(answer&.display_value) + question.options.each do |opt| + pdf.formatted_text [ + { text: selected.include?(opt) ? "[X] " : "[ ] ", styles: [:bold] }, + { text: opt, size: 9 } + ] + end + end + end + + def section_questions(application, section_number, drawn:) + if drawn + application ? application.drawn_questions.to_a : [] + else + Question.active.for_section(section_number).to_a + end + end + + # --------------------------------------------------------- Header / Footer + + def render_header(pdf, application, page_label:, title: "RUBY EMBASSY · APPLICATION FOR PASSPORT") + pdf.font_size 9 + pdf.text FORM_CODE, align: :right, style: :bold + pdf.move_down 2 + pdf.text title, size: 14, style: :bold, align: :center + pdf.text "United Embassy of Ruby · Asheville, NC", size: 9, align: :center, style: :italic + pdf.move_down 4 + pdf.stroke_horizontal_rule + pdf.move_down 4 + + serial = application&.serial || "[BLANK]" + submitted = application&.submitted_at&.strftime("%B %-d, %Y · %-l:%M %p") || "—" + booking = application&.embassy_booking + schedule_item = booking&.schedule_item + when_text = schedule_item ? "#{ScheduleItem::DAY_META.dig(schedule_item.day, :date)} · #{schedule_item.time_label}" : "—" + applicant = booking&.user&.full_name || "—" + + metadata_row(pdf, [ + ["Serial No.", serial], + ["Appointment", when_text], + ["Applicant", applicant], + ["Submitted", submitted], + ]) + pdf.move_down 6 + pdf.stroke_horizontal_rule + end + + def metadata_row(pdf, pairs) + col_width = (pdf.bounds.width / pairs.length.to_f).floor + pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width, height: 26) do + pairs.each_with_index do |(label, value), i| + pdf.bounding_box([i * col_width, pdf.bounds.top], width: col_width, height: 26) do + pdf.text label, size: 7, style: :bold, color: "555555" + pdf.text value.to_s, size: 9 + end + end + end + end + + def render_footer(pdf, application, page_label:) + pdf.repeat(:all) do + pdf.bounding_box([0, 30], width: pdf.bounds.width) do + serial = application&.serial || "[BLANK]" + pdf.stroke_horizontal_rule + pdf.move_down 2 + pdf.font_size 7 + pdf.formatted_text_box [ + { text: "Form RE-1 · Serial #{serial}", styles: [:italic] }, + { text: " · ", color: "999999" }, + { text: page_label } + ], at: [0, pdf.cursor], width: pdf.bounds.width, align: :center, size: 7 + end + end + end + + # ------------------------------------------------------------ Signature + + def render_signature_block(pdf, application) + pdf.move_down 14 + pdf.text "APPLICANT SIGNATURE", size: 9, style: :bold + pdf.stroke_horizontal_rule + pdf.move_down 8 + + signed_name = application&.embassy_application_answers + &.joins(:question)&.where(questions: { external_id: "5d" }) + &.first&.value_text + sig_field(pdf, "Signature of Applicant (typed)", width: 280, value: signed_name) + + pdf.move_up 28 + pdf.bounding_box([300, pdf.cursor + 28], width: 200) do + submitted_on = application&.submitted_at&.strftime("%Y-%m-%d") || "" + sig_field(pdf, "Date executed", width: 200, value: submitted_on) + end + + pdf.move_down 8 + end + + # --------------------------------------------------------- Drawing helpers + + def boxed_text(pdf, text, height:) + width = pdf.bounds.right - pdf.bounds.left + top = pdf.cursor + pdf.stroke do + pdf.line_width 0.5 + pdf.rectangle [0, top], width, height + end + if text.to_s.strip.length.positive? + pdf.bounding_box([6, top - 4], width: width - 12, height: height - 6) do + pdf.text text.to_s, size: 9, overflow: :shrink_to_fit + end + end + pdf.move_cursor_to(top - height - 2) + end + + def sig_field(pdf, caption, width:, value: nil) + top = pdf.cursor + pdf.line_width 0.6 + pdf.stroke_line [0, top - 14], [width, top - 14] + if value.present? + pdf.bounding_box([2, top], width: width - 4, height: 14) do + pdf.text value.to_s, size: 9 + end + end + pdf.move_down 16 + pdf.text caption, size: 7, color: "555555" + end + + def build_questions_index + @questions_index ||= Question.active.includes(:embassy_application_answers).index_by(&:id) + end +end diff --git a/app/views/admin/embassy_applications/index.html.erb b/app/views/admin/embassy_applications/index.html.erb index 9590354..ebc95d0 100644 --- a/app/views/admin/embassy_applications/index.html.erb +++ b/app/views/admin/embassy_applications/index.html.erb @@ -12,31 +12,43 @@ class: "btn btn-red" %> - - - - - - - - - - - - <% @applications.each do |app| %> +<% if @applications.empty? %> +
+ No submitted applications yet. Once attendees submit, they'll appear here. +
+<% else %> +
SerialAttendeeAppointmentSubmitted
+ - - - - - + + + + + - <% end %> - -
<%= app[:serial] %> -
<%= app[:attendee_name] %>
-
<%= app[:attendee_email] %>
-
<%= app[:slot] %><%= app[:submitted_at] %> - <%= link_to "View", admin_embassy_application_path(app[:serial]), - class: "text-blue underline" %> - SerialAttendeeAppointmentSubmitted
+ + + <% @applications.each do |application| %> + <% booking = application.embassy_booking %> + <% schedule_item = booking.schedule_item %> + <% user = booking.user %> + + <%= application.serial %> + +
<%= user.full_name %>
+
<%= user.email %>
+ + + <%= ScheduleItem::DAY_META.dig(schedule_item.day, :date) %> + · <%= schedule_item.time_label %> + + <%= application.submitted_at&.strftime("%b %-d · %-l:%M %p") %> + + <%= link_to "View", admin_embassy_application_path(application), + class: "text-blue underline" %> + + + <% end %> + + +<% end %> diff --git a/app/views/admin/embassy_applications/show.html.erb b/app/views/admin/embassy_applications/show.html.erb index e94b22e..29e86f2 100644 --- a/app/views/admin/embassy_applications/show.html.erb +++ b/app/views/admin/embassy_applications/show.html.erb @@ -1,43 +1,70 @@ -<% content_for :title, "Application #{@application[:serial]} — Embassy Admin" %> +<% content_for :title, "Application #{@application.serial} — Embassy Admin" %>

- Application <%= @application[:serial] %> + Application <%= @application.serial %>

- <%= @application[:attendee_name] %> · - <%= @application[:slot] %> · - submitted <%= @application[:submitted_at] %> + <%= @booking.user.full_name %> · + <%= ScheduleItem::DAY_META.dig(@schedule_item.day, :date) %> · <%= @schedule_item.time_label %> · + submitted <%= @application.submitted_at&.strftime("%b %-d · %-l:%M %p") %>

- + <%= link_to "Download PDF", + admin_embassy_application_path(@application, format: :pdf), + class: "btn btn-red btn-lg" %> <%= link_to "← Back", admin_embassy_applications_path, class: "btn btn-muted" %>
-<% drawn_ids = @application[:drawn_ids] %> -<% notary = FakeEmbassy.notary_for(@application) %> -<% if drawn_ids %> -
- Random pool draw: - <%= drawn_ids.join(", ") %> - (from the <%= FakeEmbassy.random_pool_total %>-question Section 3 pool). +
+ Random pool draw: + <%= @application.drawn_question_ids.join(", ") %> + (4 questions from the active Section 3 pool). + <% if @notary %>
Notary assignment: - <%= notary[:id] %> · “Someone who <%= notary[:description].downcase.sub(/^./, &:downcase) %>” -
-<% end %> + <%= @notary.external_id %> · + “Someone who <%= @notary.description.sub(/^./, &:downcase) %>” + <% end %> +
-
- <%= render "embassy_applications/pdf", - serial: @application[:serial], - schedule_item: ScheduleItem.embassy.first || ScheduleItem.first, - sections: FakeEmbassy.sections_for(@application), - notary: FakeEmbassy.notary_for(@application), - applicant_name: @application[:attendee_name], - submitted_at: @application[:submitted_at] %> +
+

Submitted Answers

+ <% [1, 2, 3, 4, 5].each do |n| %> +
+

+ Section <%= n %> · <%= embassy_section_title(n) %> +

+
+ <% @sections[n].each do |question| %> + <% answer = @application.answer_for(question) %> +
<%= question.external_id %> · <%= question.label %>
+
+ <% case question.field_type %> + <% when "checkbox_group" %> + <% values = Array(answer&.value_array) %> + <% if values.any? %> +
    + <% values.each { |v| %>
  • <%= v %>
  • <% } %> +
+ <% else %> + — None selected — + <% end %> + <% when "checkbox" %> + <%= answer&.value_text == "true" ? "✓ Affirmed" : "— Not affirmed —" %> + <% else %> + <% if answer&.value_text.present? %> + <%= simple_format(answer.value_text) %> + <% else %> + — Blank — + <% end %> + <% end %> +
+ <% end %> +
+
+ <% end %>
diff --git a/app/views/admin/embassy_blank_pdfs/create.html.erb b/app/views/admin/embassy_blank_pdfs/create.html.erb deleted file mode 100644 index 3b674a4..0000000 --- a/app/views/admin/embassy_blank_pdfs/create.html.erb +++ /dev/null @@ -1,45 +0,0 @@ -<% content_for :title, "Blank Applications Generated — Embassy Admin" %> - -

Blank Applications Ready

-

- <%= @count %> blank applications were generated. Each has a unique serial - and a unique random draw of questions. Download the combined PDF below, - print it, and bring to the Embassy for walk-ins. -

- -
-
-
READY
-
-

- ruby-embassy-blanks-<%= Date.current.strftime("%Y%m%d") %>.pdf -

-

- <%= @count %> forms · <%= @count * 2 %> pages · ~<%= (@count * 0.05).round(1) %> MB -

-
- - Download PDF - -
-
- -

Generated serials

-

Use these to reconcile paper forms with Embassy records.

- -
- -
- -
- <%= link_to "Generate Another Batch", - new_admin_embassy_blank_pdfs_path, - class: "btn btn-navy" %> - <%= link_to "Back to Applications", - admin_embassy_applications_path, - class: "btn btn-muted" %> -
diff --git a/app/views/admin/embassy_blank_pdfs/new.html.erb b/app/views/admin/embassy_blank_pdfs/new.html.erb index b204fd0..75cf7fc 100644 --- a/app/views/admin/embassy_blank_pdfs/new.html.erb +++ b/app/views/admin/embassy_blank_pdfs/new.html.erb @@ -2,11 +2,11 @@

Generate Blank Applications

- Print blank, randomized applications for on-site walk-ins. Each generated form - contains a unique, randomly-drawn set of questions from the active bank - and a unique notary assignment. Forms are serialized - sequentially (RE-0427-A, RE-0427-B, …) so paper can be reconciled with - Embassy records later. + Print blank applications for on-site walk-ins. Each blank shows the + current Section 1, 2, 4, and 5 questions; Section 3 questions and the + notary assignment are filled in by the Embassy Attaché when an applicant + picks one up. Submit the form below to download a single PDF containing + all the blanks you need.

@@ -19,25 +19,8 @@
- + <%= link_to "Cancel", admin_embassy_applications_path, class: "btn btn-muted" %>
<% end %>
- -

Preview · one blank form

-

- A randomly-generated blank with serial <%= @preview_serial %> - and notary assignment <%= @preview_notary[:id] %> - (“<%= @preview_notary[:description] %>”). - The real batch will randomize both per form. -

- -
- <%= render "embassy_applications/pdf", - serial: @preview_serial, - schedule_item: ScheduleItem.embassy.first || ScheduleItem.first, - sections: @preview_sections, - notary: @preview_notary, - blank: true %> -
diff --git a/app/views/admin/embassy_questions/_form.html.erb b/app/views/admin/embassy_questions/_form.html.erb index 99d72e6..f503da3 100644 --- a/app/views/admin/embassy_questions/_form.html.erb +++ b/app/views/admin/embassy_questions/_form.html.erb @@ -1,69 +1,87 @@ -<%# locals: question:, url:, method: :post %> +<%# locals: (question:) -%> +<% if question.errors.any? %> +
+ +
+<% end %> +
- <%= form_with url: url, method: method, local: true, scope: :question, class: "space-y-4" do |f| %> + <%= form_with model: [:admin, question], local: true, class: "space-y-4" do |f| %>
- - > + <%= f.label :external_id, "Question ID", class: "label" %> + <%= f.text_field :external_id, class: "input mono", + placeholder: "e.g., 1a", + readonly: question.persisted? %>

Used on printed forms (1a, 1b, 2c, etc.). Once assigned, do not change.

- - + <%= f.label :section, class: "label" %> + <%= f.select :section, + (1..5).map { |n| ["Section #{n.to_s.rjust(2, '0')} · #{EmbassyQuestionsHelper::SECTION_META[n][:title]}", n] }, + {}, class: "select" %> +
+ +
+ <%= f.label :scope, class: "label" %> + <%= f.select :scope, + [["Common (shown on every application)", "common"], + ["Random pool (drawn for some applications)", "random_pool"]], + {}, class: "select" %>

- Section scope (common vs random pool) is set per section. Questions - added to a random-pool section become part of that section's draw. + Use "Random pool" only for Section 3 questions.

- - + <%= f.label :label, "Question text", class: "label" %> + <%= f.text_area :label, rows: 2, class: "input" %> +
+ +
+ <%= f.label :field_type, "Type", class: "label" %> + <%= f.select :field_type, + Question.field_types.keys.map { |t| [t.humanize, t] }, + {}, class: "select" %>
- - + <%= f.label :help, "Help text (optional)", class: "label" %> + <%= f.text_field :help, class: "input", + placeholder: "e.g., Literal or metaphorical answers both accepted." %>
- - + <%= f.label :placeholder, "Placeholder (optional, shown inside empty inputs)", class: "label" %> + <%= f.text_field :placeholder, class: "input" %>
- - + +
- > - + <%= f.check_box :required %> + <%= f.label :required, "Required for adjudication" %> +
+ +
+ <%= f.label :status, class: "label" %> + <%= f.select :status, + [["Active", "active"], ["Archived", "archived"]], + {}, class: "select" %>
- + <%= f.submit "Save Question", class: "btn btn-navy" %> <%= link_to "Cancel", admin_embassy_questions_path, class: "btn btn-muted" %>
<% end %> diff --git a/app/views/admin/embassy_questions/_question_row.html.erb b/app/views/admin/embassy_questions/_question_row.html.erb index 074c165..8cda342 100644 --- a/app/views/admin/embassy_questions/_question_row.html.erb +++ b/app/views/admin/embassy_questions/_question_row.html.erb @@ -1,22 +1,22 @@ -<%# locals: question: %> -<%= turbo_frame_tag "question_#{question[:id]}", target: "_top" do %> - - <%= question[:id] %> - <%= question[:label] %> - <%= question[:type].to_s.humanize %> - <%= question[:required] ? "Yes" : "—" %> - - <%= question[:usage_count] %> - uses - - - <%= link_to "Edit", edit_admin_embassy_question_path(question[:id]), - class: "text-blue underline" %> - <%= button_to "Archive", admin_embassy_question_path(question[:id]), +<%# locals: (question:) -%> + + <%= question.external_id %> + <%= question.label %> + <%= question.field_type.humanize %> + <%= question.required? ? "Yes" : "—" %> + + <%= question.respond_to?(:computed_usage_count) ? question.computed_usage_count : question.usage_count %> + uses + + + <%= link_to "Edit", edit_admin_embassy_question_path(question), + class: "text-blue underline" %> + <% if question.active? %> + <%= button_to "Archive", admin_embassy_question_path(question), method: :delete, - data: { turbo_confirm: "Archive question #{question[:id]}? It will stop appearing in new randomizations." }, + data: { turbo_confirm: "Archive question #{question.external_id}? It will stop appearing in new applications." }, class: "btn-ghost ml-2", style: "display:inline;color:#C41C1C;padding:0;box-shadow:none" %> - - -<% end %> + <% end %> + + diff --git a/app/views/admin/embassy_questions/edit.html.erb b/app/views/admin/embassy_questions/edit.html.erb index b4b9246..63d0aa7 100644 --- a/app/views/admin/embassy_questions/edit.html.erb +++ b/app/views/admin/embassy_questions/edit.html.erb @@ -1,10 +1,7 @@ -<% content_for :title, "Edit Question #{@question[:id]} — Embassy Admin" %> +<% content_for :title, "Edit Question #{@question.external_id} — Embassy Admin" %>

- Edit Question <%= @question[:id] %> + Edit Question <%= @question.external_id %>

-<%= render "form", - question: @question, - url: admin_embassy_question_path(@question[:id]), - method: :patch %> +<%= render "form", question: @question %> diff --git a/app/views/admin/embassy_questions/index.html.erb b/app/views/admin/embassy_questions/index.html.erb index 2b4970f..fbb7c1c 100644 --- a/app/views/admin/embassy_questions/index.html.erb +++ b/app/views/admin/embassy_questions/index.html.erb @@ -4,32 +4,33 @@

Question Bank

- <%= @questions.count { |q| q[:status] == "active" } %> active questions · - <%= FakeEmbassy.random_pool_total %> in the random pool (draws <%= FakeEmbassy.random_pool_draws %> per application) · - <%= @notary_pool.length %> notary descriptions + <%= @questions.count(&:active?) %> active questions · + <%= @questions.count { |q| q.random_pool? && q.active? } %> in the random pool (draws <%= EmbassyApplicationDraw::POOL_SIZE %> per application) · + <%= @notary_pool.size %> notary descriptions

<%= link_to "+ Add Question", new_admin_embassy_question_path, class: "btn btn-red" %>
-<% FakeEmbassy.section_meta.each do |number, meta| %> - <% rows = @questions.select { |q| q[:section] == number && q[:status] == "active" } %> +<% [1, 2, 3, 4, 5].each do |number| %> + <% rows = (@questions_by_section[number] || []).select(&:active?) %> <% next if rows.empty? %> + <% section_meta = EmbassyQuestionsHelper::SECTION_META[number] %> + <% scope = rows.first.scope %> +

- Section <%= number.to_s.rjust(2, "0") %>. <%= meta[:title] %> + Section <%= number.to_s.rjust(2, "0") %>. <%= section_meta[:title] %>

-

- <%= meta[:hint] % { draws: FakeEmbassy.random_pool_draws, total: rows.size } %> -

+

<%= section_meta[:instructions] %>

- <% if meta[:scope] == "random_pool" %> + <% if scope == "random_pool" %> - Random pool · draws <%= FakeEmbassy.random_pool_draws %> of <%= rows.size %> + Random pool · draws <%= EmbassyApplicationDraw::POOL_SIZE %> of <%= rows.size %> <% else %> @@ -39,69 +40,39 @@
- <% if meta[:scope] == "random_pool" %> - <%# Section 3 is nested by category for easier admin management %> - <% FakeEmbassy.random_pool_categories.each do |cat_key, cat| %> - <% cat_rows = rows.select { |q| q[:category] == cat_key } %> - <% next if cat_rows.empty? %> - -

- <%= cat[:title] %> - <%= cat_rows.size %> questions -

- - - - - - - - - - - - <% cat_rows.each do |q| %> - <%= render "question_row", question: q %> - <% end %> - -
IDQuestionTypeUsage
- <% end %> - <% else %> - - - - - - - - - - - - - <% rows.each do |q| %> - <%= render "question_row", question: q %> - <% end %> - -
IDQuestionTypeRequiredUsage
- <% end %> + + + + + + + + + + + + + <% rows.each do |q| %> + <%= render "question_row", question: q %> + <% end %> + +
IDQuestionTypeRequiredUsage
<% end %> -<%# ========== Notary Pool — PDF addendum ========== %> +<%# ========== Notary Pool ========== %>

Notary Pool · PDF addendum

The ice-breaker. One is drawn per printed application. Users locate someone - matching the description at the Embassy and have them sign. Notary - descriptions do not appear on-screen — they print only. + matching the description at the Embassy and have them sign.

- Notary pool · <%= @notary_pool.length %> descriptions · draws 1 per form + Notary pool · <%= @notary_pool.size %> descriptions · draws 1 per form
@@ -111,42 +82,34 @@ ID Description (“Someone who…”) - Follow-ups + Follow-up prompt Usage - <% @notary_pool.each do |n| %> - <%= n[:id] %> - <%= n[:description] %> + <%= n.external_id %> + <%= n.description %> + <%= n.followup_prompt %> - <%= n[:followup_count] %> - q<%= n[:followup_count] == 1 ? "" : "s" %> - - - <%= n[:usage_count] %> + <%= n.usage_count %> uses - - edit coming - <% end %>
-<% archived = @questions.select { |q| q[:status] == "archived" } %> +<% archived = @questions.select(&:archived?) %> <% if archived.any? %>

Archived

- Excluded from randomization. Useful for retired seasonal questions - or ones pulled for adjudication reasons. + Excluded from new applications. Historical answers still reference these.

diff --git a/app/views/admin/embassy_questions/new.html.erb b/app/views/admin/embassy_questions/new.html.erb index 44af084..a55a176 100644 --- a/app/views/admin/embassy_questions/new.html.erb +++ b/app/views/admin/embassy_questions/new.html.erb @@ -2,7 +2,4 @@

Add Question to the Bank

-<%= render "form", - question: @question, - url: admin_embassy_questions_path, - method: :post %> +<%= render "form", question: @question %> diff --git a/app/views/admin/schedule_items/_form.html.erb b/app/views/admin/schedule_items/_form.html.erb index f795615..3da7acc 100644 --- a/app/views/admin/schedule_items/_form.html.erb +++ b/app/views/admin/schedule_items/_form.html.erb @@ -9,7 +9,9 @@ <% end %>
- <%= form_with model: [ :admin, schedule_item ], class: "space-y-4" do |f| %> + <%= form_with model: [ :admin, schedule_item ], + class: "space-y-4", + data: { controller: "embassy-settings" } do |f| %>
<%= f.label :title, class: "label" %> <%= f.text_field :title, required: true, class: "input" %> @@ -19,7 +21,9 @@ <%= f.label :kind, class: "label" %> <%= f.select :kind, ScheduleItem.kinds.keys.map { |k| [ k.humanize, k ] }, - {}, class: "select" %> + {}, + class: "select", + data: { embassy_settings_target: "kindSelect", action: "change->embassy-settings#toggle" } %>
@@ -77,37 +81,33 @@ <%= f.label :is_public, "Public (appears on /schedule for all attendees)" %>
-
> - - Embassy-only settings - · expand when Kind = Embassy - - -
-

- These fields only take effect for items where Kind = Embassy. - Rendered as raw inputs for the mockup — a backend pass adds - capacity and embassy_mode columns to - schedule_items. -

+
> +

Embassy-only settings

+

+ These fields only apply when Kind = Embassy. They control + capacity (number of bookable seats) and mode (whether attendees come for a + new passport, an existing-passport stamping, or either). +

-
- - -
+
+ <%= f.label :embassy_capacity, "Capacity (seats per block)", class: "label" %> + <%= f.number_field :embassy_capacity, min: 1, max: 50, + class: "input", style: "max-width: 160px;", + placeholder: "e.g., 8" %> +
-
- - -
+
+ <%= f.label :embassy_mode, "Embassy mode", class: "label" %> + <%= f.select :embassy_mode, + [ [ "New passport only (application required)", "new_passport" ], + [ "Stamping only (existing passport)", "stamping" ], + [ "New passport or Stamping (user chooses)", "both" ] ], + { include_blank: "— Select —" }, + class: "select" %>
-
+
<%= f.submit class: "btn btn-navy" %> diff --git a/app/views/embassy_applications/_appointment_card.html.erb b/app/views/embassy_applications/_appointment_card.html.erb index 4cb8c15..050041c 100644 --- a/app/views/embassy_applications/_appointment_card.html.erb +++ b/app/views/embassy_applications/_appointment_card.html.erb @@ -1,6 +1,6 @@ -<%# locals: (serial:, schedule_item:, mode: "new_passport", state: "default") -%> +<%# locals: (application:, schedule_item:, state: "default") -%>
-
<%= serial %>
+
<%= application.serial %>

Ruby Embassy Appointment

@@ -16,9 +16,9 @@
Classification
-
<%= FakeEmbassy.mode_label(mode) %>
+
<%= embassy_mode_label(application.embassy_booking.mode) %>
Applicant
-
<%= current_user.full_name %>
+
<%= application.user.full_name %>
diff --git a/app/views/embassy_applications/_expired.html.erb b/app/views/embassy_applications/_expired.html.erb deleted file mode 100644 index 5d2d693..0000000 --- a/app/views/embassy_applications/_expired.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<%# locals: (plan_item:, schedule_item:) -%> -
-
SESSION EXPIRED
- -

The Applicant's session has lapsed.

- -

- The <%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> - appointment at <%= schedule_item.time_label %> remains confirmed in - Embassy records. The Applicant may commence a fresh application at any - time; a new form shall be issued. -

- - <%= link_to "Start a New Application", - new_embassy_application_path(plan_item_id: plan_item.id), - class: "btn btn-red", - data: { turbo_frame: "_top" } %> -
diff --git a/app/views/embassy_applications/_pdf.html.erb b/app/views/embassy_applications/_pdf.html.erb deleted file mode 100644 index fe1b7fc..0000000 --- a/app/views/embassy_applications/_pdf.html.erb +++ /dev/null @@ -1,179 +0,0 @@ -<%# locals: (serial:, schedule_item:, sections:, notary: nil, applicant_name: nil, submitted_at: nil, blank: false) -%> -
-
-
-
- Form RUBY-1.0-MATZ
- Rev. 04/2026
- Case No. 4-2-0-9-GEM -
- - -
- -

RUBY EMBASSY

-

Application for Issuance of New Ruby Passport

- - <% if blank %> -
-
Appointment
 
-
Applicant
 
-
Submitted
 
-
- <% else %> -
-
Appointment
-
<%= ScheduleItem::DAY_META.dig(schedule_item.day, :label) %> · <%= schedule_item.time_label %>
-
Applicant
-
<%= applicant_name || current_user.full_name %>
-
Submitted
-
<%= submitted_at || Time.current.strftime("%Y-%m-%d %H:%M") %>
-
- <% end %> -
- - <% sections.each do |section| %> -
-

- Section <%= section[:number].to_s.rjust(2, "0") %>. <%= section[:title] %> -

- <% section[:questions].each do |q| %> -
- <%= q[:id] %>. -
-

<%= q[:label] %>

-
- <% ans = blank ? nil : FakeEmbassy.filled_answer(q[:id]) %> - <% case q[:type] %> - <% when :checkbox %> - <%= ans ? "☒ I affirm" : "☐" %> - <% when :checkbox_group %> -
    - <% q[:options].each do |opt| %> - <% checked = ans.is_a?(Array) && ans.include?(opt) %> -
  • <%= checked ? "☒" : "☐" %> <%= opt %>
  • - <% end %> -
- <% when :long %> - <%= ans %> - <% else %> - <%= ans %> - <% end %> -
-
-
- <% end %> -
- <% end %> - -
-
-
- <% if blank %> -   - <% else %> - X   <%= FakeEmbassy.filled_answer("5d") %> - <% end %> -
-
- <% if blank %> -   - <% else %> - <%= Date.current.strftime("%Y-%m-%d") %> - <% end %> -
-
-
- Signature of Applicant - Date -
-
- -
- <%= serial %> - Page 1 of 2 · Bring this form to your appointment -
-
- -<%# ==================== Notary Addendum ==================== %> -<%# A second printed page. The notary description is randomized per -<%# application — this is the ice-breaker mechanic. Users MUST NOT see -<%# this on screen before printing. It's rendered inside the PDF canvas -<%# which @media print exposes while hiding everything else. %> -<% if notary %> -
-
-

NOTARY CERTIFICATION

-

Required for Validation at the Embassy Gate · Ordinance §4.2(b)

-
- -
-

- Pursuant to Embassy Ordinance §4.2(b), every application for issuance - of a new Ruby Embassy Passport must be certified by a qualified - individual physically present within the conference grounds. The - Applicant's assigned notary description has been determined at the - time of form issuance and is recorded below. Identification and - procurement of the notary is the sole responsibility of the Applicant. -

-
- -
-

Someone who…

-

<%= notary[:description] %>

-
- -
-

Supplementary Particulars

- <% notary[:followups].each_with_index do |f, i| %> -
- N<%= (i + 1).to_s.rjust(2, "0") %>. -
-

<%= f %>

-
-
-
- <% end %> -
- -
-

Certified By

- -
- Name - -
-
- Signature - -
-
- Official Title - -
-
- -
- <%= serial %> · <%= notary[:id] %> - Page 2 of 2 · Bring this form to the Embassy -
-
-<% end %> diff --git a/app/views/embassy_applications/_question.html.erb b/app/views/embassy_applications/_question.html.erb index 13782b6..2242a67 100644 --- a/app/views/embassy_applications/_question.html.erb +++ b/app/views/embassy_applications/_question.html.erb @@ -1,67 +1,79 @@ -<%# locals: (question:) -%> -
-