Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/controllers/v0/form22_1990n_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module V0
class Form22_1990nController < ApplicationController
before_action :authenticate
before_action :require_loa3

def create
submission = Form22_1990nSubmission.new(
form_data: form22_1990n_params[:form_data],
user_uuid: current_user.uuid
)

unless submission.valid?
render json: { errors: submission.errors.full_messages },
status: :unprocessable_entity
return
end

submission.save!

StatsD.increment('edu.1990n.submission.attempt')
Lighthouse::SubmitForm22_1990nJob.perform_async(submission.id)
StatsD.increment('edu.1990n.submission.success')

render json: Form22_1990nSerializer.new(submission).serializable_hash,
status: :ok
rescue => e
StatsD.increment('edu.1990n.submission.failure', tags: ["error:#{e.class}"])
raise
end

private

def form22_1990n_params
params.require(:form22_1990n).permit(:form_data)
end

def require_loa3
unless current_user.loa3?
render json: { errors: ['LOA3 is required to submit this form'] },
status: :forbidden
nil
end
end
end
end
33 changes: 33 additions & 0 deletions app/models/form22_1990n_submission.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

class Form22_1990nSubmission < ApplicationRecord
self.table_name = 'form22_1990n_submissions'

# KMS-encrypted storage of PII-bearing form payload.
# attr_encrypted delegates actual encryption to the KMS client
# wired up in config/initializers/attr_encrypted.rb.
attr_encrypted :form_data,
key: Settings.db_encryption_key,
mode: :per_attribute_iv,
encode: true,
encode_iv: true,
attribute: 'encrypted_form_data',
iv_attribute: 'encrypted_form_data_iv'

validates :form_data, presence: true
validates :user_uuid, presence: true

scope :pending, -> { where(submitted_at: nil) }

# Returns a human-readable confirmation number surfaced to veterans
# on the confirmation page. Prefixed with '1990N-' so support staff
# can immediately identify the form type from the confirmation string.
def confirmation_number
"1990N-#{id}"
end

# Convenience predicate used by the Sidekiq job and monitoring.
def submitted?
submitted_at.present?
end
end
19 changes: 19 additions & 0 deletions app/serializers/form22_1990n_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

class Form22_1990nSerializer < ActiveModel::Serializer
type 'form22_1990n_submission'

attributes :status, :confirmation_number, :submitted_at

def status
object.submitted? ? 'submitted' : 'pending'
end

def confirmation_number
object.confirmation_number
end

def submitted_at
object.submitted_at&.iso8601
end
end
114 changes: 114 additions & 0 deletions app/sidekiq/lighthouse/submit_form22_1990n_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

require 'lighthouse/benefits_intake/service'

module Lighthouse
class SubmitForm22_1990nJob
include Sidekiq::Worker

sidekiq_options retry: 5, queue: 'default'

STATS_KEY_BASE = 'edu.1990n.lighthouse'
FORM_ID = '22-1990N'

def perform(submission_id)
submission = Form22_1990nSubmission.find(submission_id)

if submission.submitted?
log_message_to_sentry(
"Form22_1990nSubmission #{submission_id} already submitted; skipping.",
:warn
)
return
end

StatsD.measure("#{STATS_KEY_BASE}.perform.duration") do
upload_to_benefits_intake(submission)
end
rescue ActiveRecord::RecordNotFound => e
StatsD.increment("#{STATS_KEY_BASE}.failure",
tags: ['error:record_not_found'])
raise e
rescue => e
StatsD.increment("#{STATS_KEY_BASE}.failure",
tags: ["error:#{e.class}"])
raise e
end

private

def upload_to_benefits_intake(submission)
service = BenefitsIntakeService::Service.new

response = service.upload_form(
form_path: build_form_payload(submission),
metadata_path: build_metadata_payload(submission),
form_id: FORM_ID
)

submission.update!(submitted_at: Time.current)

StatsD.increment("#{STATS_KEY_BASE}.success")

response
end

# Writes the raw form_data JSON to a Tempfile and returns its path.
# BenefitsIntakeService expects a file path for multipart upload.
def build_form_payload(submission)
tmpfile = Tempfile.new(["form22_1990n_#{submission.id}", '.json'])
tmpfile.write(submission.form_data)
tmpfile.rewind
tmpfile.path
ensure
tmpfile&.close
end

def build_metadata_payload(submission)
metadata = {
veteranFirstName: extract_first_name(submission),
veteranLastName: extract_last_name(submission),
fileNumber: extract_ssn(submission),
zipCode: extract_zip(submission),
source: 'VA.gov',
docType: FORM_ID,
businessLine: 'EDU'
}

tmpfile = Tempfile.new(["form22_1990n_meta_#{submission.id}", '.json'])
tmpfile.write(metadata.to_json)
tmpfile.rewind
tmpfile.path
ensure
tmpfile&.close
end

def parsed_form_data(submission)
@parsed_form_data ||= JSON.parse(submission.form_data)
end

def extract_first_name(submission)
parsed_form_data(submission).dig(
'applicantInformation', 'veteranFullName', 'first'
).to_s
end

def extract_last_name(submission)
parsed_form_data(submission).dig(
'applicantInformation', 'veteranFullName', 'last'
).to_s
end

def extract_ssn(submission)
parsed_form_data(submission).dig(
'applicantInformation', 'veteranSocialSecurityNumber'
).to_s
end

def extract_zip(submission)
parsed_form_data(submission).dig(
'applicantInformation', 'veteranAddress', 'postalCode'
).to_s[0, 5]
end
end
end
10 changes: 10 additions & 0 deletions config/routes/form22_1990n.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

# This file is drawn from config/routes.rb via:
# draw :form22_1990n
# which calls Rails.application.routes.draw { eval(IO.read(file)) }.
# Only the actions actually served are registered — no index/show/destroy.

namespace :v0 do
post 'form22_1990n', to: 'form22_1990n#create'
end
31 changes: 31 additions & 0 deletions db/migrate/20260426223952_create_form22_1990n_submissions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

class CreateForm22_1990nSubmissions < ActiveRecord::Migration[7.0]
def change
create_table :form22_1990n_submissions do |t|
# User association — nullable so a record can be reconstructed
# even if the user row is expunged during account deletion.
t.uuid :user_uuid, null: false

# Unencrypted jsonb column kept for schema completeness.
# In practice the application always reads/writes via the
# attr_encrypted virtual attribute; this column is blank.
t.jsonb :form_data

# KMS-encrypted columns — hold the actual PII payload.
t.text :encrypted_form_data, null: true
t.text :encrypted_form_data_iv, null: true

# NULL until the Sidekiq job successfully posts to Lighthouse.
t.datetime :submitted_at, null: true

t.timestamps null: false
end

add_index :form22_1990n_submissions, :user_uuid
add_index :form22_1990n_submissions, :submitted_at
add_index :form22_1990n_submissions,
:created_at,
name: 'index_form22_1990n_submissions_on_created_at'
end
end
Loading