diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5b4ca6b5..9a8eac9a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -44,6 +44,16 @@ def authenticate_identity! end end + # an open manual verification call case supersedes the self-serve + # verification flows — controllers serving those flows use this as a + # before_action so users land on their case instead of a legacy page + def redirect_to_open_manual_case + return unless Flipper.enabled?(VerificationCase::FLIPPER_FLAG, current_identity) + return unless current_identity.verification_cases.open_cases.exists? + + redirect_to manual_verification_path + end + def set_honeybadger_context return unless current_identity diff --git a/app/controllers/backend/break_glass_controller.rb b/app/controllers/backend/break_glass_controller.rb index a84a175d..9e2c1c76 100644 --- a/app/controllers/backend/break_glass_controller.rb +++ b/app/controllers/backend/break_glass_controller.rb @@ -33,6 +33,11 @@ def find_break_glassable Identity::PersonaRecord.find(params[:break_glassable_id]) when "Identity" Identity.find_by_public_id!(params[:break_glassable_id]) + when "VerificationCase::Document" + VerificationCase::Document.find(params[:break_glassable_id]).tap do |doc| + doc.verification_case.log_event!(:document_break_glass, actor: current_user, + data: { document_id: doc.id, reason: params[:reason] }, request: request) + end else raise ArgumentError, "Invalid break_glassable_type: #{params[:break_glassable_type]}" end @@ -49,6 +54,8 @@ def document_type "persona record" when "Identity" "identity" + when "VerificationCase::Document" + "case document" else "item" end diff --git a/app/controllers/backend/verification_cases_controller.rb b/app/controllers/backend/verification_cases_controller.rb new file mode 100644 index 00000000..80838f13 --- /dev/null +++ b/app/controllers/backend/verification_cases_controller.rb @@ -0,0 +1,166 @@ +module Backend + class VerificationCasesController < ApplicationController + before_action :set_case, except: [ :index, :create ] + + def index + authorize VerificationCase + add_breadcrumb "CASES" + + set_keyboard_shortcut(:back, backend_root_path) + + @open_cases = VerificationCase.open_cases + .includes(:identity, :opened_by) + .order(created_at: :asc) + .page(params[:page]).per(20) + @decided_cases = VerificationCase.where(status: %w[approved denied]) + .includes(:identity, :verification) + .order(updated_at: :desc) + .page(params[:decided_page]).per(10) + end + + def show + authorize @case + add_breadcrumb "CASES", backend_verification_cases_path + add_breadcrumb @case.public_id + + set_keyboard_shortcut(:back, backend_verification_cases_path) + + @events = @case.events.recent_first.includes(:actor) + @documents = @case.documents + @comments = @case.comments.chronological.includes(author: :identity) + end + + # staff entry point: user emailed identity@, staff opens a case. + # enables the flipper flag + sends the single-use link. + def create + authorize VerificationCase + + identity = Identity.find_by_public_id!(params[:identity_id]) + + if identity.verification_cases.open_cases.exists? + flash[:warning] = "This identity already has an open case" + redirect_to backend_identity_path(identity) and return + end + + @case = VerificationCase.create!( + identity: identity, + opened_by: current_user, + skip_persona: params[:skip_persona] == "1" + ) + @case.enable_flag! + deliver_link! + + @case.log_event!(:case_opened, actor: current_user, request: request, + data: { skip_persona: @case.skip_persona? }) + + flash[:success] = "Case opened and link sent to #{identity.primary_email}" + redirect_to backend_verification_case_path(@case) + end + + def resend_link + authorize @case + + deliver_link! + @case.log_event!(:link_resent, actor: current_user, request: request) + + flash[:success] = "Fresh link sent to #{@case.identity.primary_email}" + redirect_to backend_verification_case_path(@case) + end + + def hold_call + authorize @case + + @case.hold_call! + @case.log_event!(:call_held, actor: current_user, request: request) + + flash[:success] = "Call marked as held — record the decision below" + redirect_to backend_verification_case_path(@case) + end + + def comment + authorize @case + + @case.comments.create!(author: current_user, body: params[:body]) + + redirect_to backend_verification_case_path(@case) + end + + def decide + authorize @case + + decision = params[:decision] + unless %w[approve deny].include?(decision) + flash[:error] = "Decision must be approve or deny" + redirect_to backend_verification_case_path(@case) and return + end + + verification = build_verification + + ActiveRecord::Base.transaction do + verification.save! + @case.update!(verification: verification) + + if decision == "approve" + verification.approve! + @case.approve! + else + verification.mark_as_rejected!(params[:rejection_reason], params[:rejection_reason_details]) + @case.deny! + end + end + + @case.log_event!(:"decision_#{decision}", actor: current_user, request: request, + data: { verification_id: verification.id, checklist: verification.checklist }) + verification.create_activity(key: "verification.#{decision == 'approve' ? 'approve' : 'reject'}", + owner: current_user, recipient: @case.identity) + + VerificationMailer.approved(verification).deliver_later if decision == "approve" + + flash[:success] = "Case #{decision == 'approve' ? 'approved' : 'denied'}" + redirect_to backend_verification_case_path(@case) + end + + rescue_from AASM::InvalidTransition do + flash[:warning] = "That action isn't valid for this case's current state (#{@case&.status})" + redirect_to @case ? backend_verification_case_path(@case) : backend_verification_cases_path + end + + rescue_from ActiveRecord::RecordInvalid do |exception| + flash[:error] = "Could not save: #{exception.record.errors.full_messages.to_sentence}" + redirect_to backend_verification_case_path(@case) + end + + private + + def set_case + @case = VerificationCase + .includes(:identity, :opened_by, :verification, documents: { file_attachment: :blob }) + .find_by_public_id!(params[:id]) + end + + def deliver_link! + token = @case.generate_access_token! + @case.send_link! + VerificationCaseMailer.invitation(@case, token).deliver_later + end + + def build_verification + checklist = {} + Verification::ManualVerificationCall::CHECKLIST_ITEMS.each_key do |item| + checklist[item] = params.dig(:checklist, item) == "yes" if params.dig(:checklist, item).present? + end + # no selfie on this case (persona-less direct upload) — nothing to + # compare the document against, so the item is recorded as n/a + checklist["doc_matches_selfie"] = nil unless @case.selfie_available? + checklist["confidence"] = params[:confidence] + checklist["notes"] = params[:notes].presence + + Verification::ManualVerificationCall.new( + identity: @case.identity, + reviewer: current_user, + checklist: checklist, + expires_at: @case.alternative? ? VerificationCase::ALTERNATIVE_DOCS_EXPIRY.from_now : nil + ) + end + end +end diff --git a/app/controllers/manual_verifications_controller.rb b/app/controllers/manual_verifications_controller.rb new file mode 100644 index 00000000..7d1783f5 --- /dev/null +++ b/app/controllers/manual_verifications_controller.rb @@ -0,0 +1,177 @@ +# the user side of a manual verification call case. gated three ways: +# logged in (ApplicationController), flipper flag on the identity, and +# a single-use emailed link consumed on first visit. +class ManualVerificationsController < ApplicationController + before_action :set_case + before_action :require_case_access + + def show + @document_class_selected = @case.document_class.present? + @documents = @case.documents.where.not(source: "call_recording") + end + + def choose_document_class + unless @case.link_sent? + redirect_to manual_verification_path and return + end + + document_class = params[:document_class] + unless %w[government_id alternative].include?(document_class) + flash[:error] = "Pick one of the two options" + redirect_to manual_verification_path and return + end + + # the alternative-docs path gets one more nudge back toward government ID + if document_class == "alternative" && params[:nudge_confirmed] != "true" + @show_alternative_nudge = true + @documents = @case.documents.where.not(source: "call_recording") + render :show and return + end + + alternative = document_class == "alternative" + @case.update!( + document_class: document_class, + alternative_reason: alternative ? params[:alternative_reason] : nil, + alternative_reason_details: alternative ? params[:alternative_reason_details] : nil + ) + @case.log_event!(:document_class_selected, actor: current_identity, request: request, + data: { document_class: document_class, reason: (params[:alternative_reason] if alternative) }.compact) + + redirect_to manual_verification_path + rescue ActiveRecord::RecordInvalid => e + flash[:error] = e.record.errors.full_messages.to_sentence + redirect_to manual_verification_path + end + + # launch the embedded persona capture-only inquiry (if a template is + # configured for this document class — otherwise the direct upload form is shown) + def start_capture + unless @case.link_sent? && @case.document_class.present? && !@case.skip_persona? + redirect_to manual_verification_path and return + end + + if @case.persona_inquiry_id.blank? + inquiry = @case.generate_capture_inquiry! + if inquiry.nil? + flash[:info] = "Direct upload it is — no capture flow configured for this document class" + redirect_to manual_verification_path and return + end + @case.log_event!(:capture_inquiry_created, actor: current_identity, request: request, + data: { inquiry_id: @case.persona_inquiry_id }) + end + + @session_token = @case.persona_session_token + @inquiry_id = @case.persona_inquiry_id + @environment_id = Rails.application.credentials.dig(:persona, :environment_id) + @persona_host = Rails.application.credentials.dig(:persona, :host) + render :capture + rescue Persona::APIError => e + Sentry.capture_exception(e, tags: { component: "persona" }) + flash[:error] = "Couldn't start the capture flow — you can upload directly instead" + redirect_to manual_verification_path + end + + # direct upload fallback for either document class + def submit_documents + unless @case.link_sent? && @case.document_class.present? + redirect_to manual_verification_path and return + end + + unless params[:attested] == "1" && params[:biometric_consent] == "1" + flash[:error] = "Both the attestation and the consent checkbox are required" + redirect_to manual_verification_path and return + end + + if params[:primary_doc].blank? + flash[:error] = "A document is required" + redirect_to manual_verification_path and return + end + + # skip-persona cases are camera-capture only — the document AND a live + # selfie, both JPEG/PNG straight from the camera widget, never an + # arbitrary uploaded file + if @case.skip_persona? + if params[:selfie].blank? + flash[:error] = "A selfie is required" + redirect_to manual_verification_path and return + end + + unless [ params[:primary_doc], params[:selfie] ].all? { |f| f.content_type.to_s.match?(%r{\Aimage/(jpeg|png)\z}) } + flash[:error] = "Your photos have to come straight from your camera" + redirect_to manual_verification_path and return + end + end + + ActiveRecord::Base.transaction do + @case.update!( + attested: true, + biometric_consent: true, + submitted_fields: submitted_fields + ) + + primary = @case.documents.new(document_kind: "primary_doc", source: "direct_upload") + primary.file.attach(params[:primary_doc]) + primary.save! + + if @case.skip_persona? + selfie = @case.documents.new(document_kind: "selfie", source: "direct_upload") + selfie.file.attach(params[:selfie]) + selfie.save! + end + + @case.submit_docs! + end + + @case.log_event!(:docs_submitted, actor: current_identity, request: request, + data: { source: "direct_upload", fields: submitted_fields.keys }) + + flash[:success] = "Documents received — book your call below" + redirect_to manual_verification_path + rescue ActiveRecord::RecordInvalid => e + flash[:error] = e.record.errors.full_messages.to_sentence + redirect_to manual_verification_path + end + + # recording disclosure must be acknowledged before the booking link shows + def acknowledge_recording + unless @case.booking_available? + redirect_to manual_verification_path and return + end + + @case.update!(recording_consent_acknowledged: true) + @case.log_event!(:recording_consent_acknowledged, actor: current_identity, request: request) + + redirect_to manual_verification_path + end + + private + + def set_case + unless Flipper.enabled?(VerificationCase::FLIPPER_FLAG, current_identity) + redirect_to root_path and return + end + + @case = current_identity.verification_cases.open_cases.order(created_at: :desc).first + redirect_to root_path if @case.nil? + end + + # first visit must carry the emailed single-use token; after it's been + # consumed the authenticated session is enough. + def require_case_access + return if performed? + return if @case.access_token_used_at.present? + + if @case.consume_access_token!(params[:token]) + @case.log_event!(:link_consumed, actor: current_identity, request: request) + redirect_to manual_verification_path if params[:token].present? && request.get? + else + @case.log_event!(:link_rejected, actor: current_identity, request: request) + render :link_invalid, status: :forbidden + end + end + + def submitted_fields + params.permit(:legal_name, :date_of_birth, :country, :address, :document_type, :issuing_authority) + .to_h.compact_blank + end +end diff --git a/app/controllers/portal/verifications_controller.rb b/app/controllers/portal/verifications_controller.rb index 0cedcdbd..f061745c 100644 --- a/app/controllers/portal/verifications_controller.rb +++ b/app/controllers/portal/verifications_controller.rb @@ -3,6 +3,7 @@ class Portal::VerificationsController < Portal::BaseController before_action :validate_portal_return_url, only: [ :start ] before_action :store_return_url, only: [ :start ] + before_action :redirect_to_open_manual_case def start @identity = current_identity diff --git a/app/controllers/verifications_controller.rb b/app/controllers/verifications_controller.rb index b9fb8b18..a8bb3fda 100644 --- a/app/controllers/verifications_controller.rb +++ b/app/controllers/verifications_controller.rb @@ -4,6 +4,9 @@ class VerificationsController < ApplicationController include AhoyAnalytics before_action :set_identity + # an open manual verification call case supersedes the self-serve flows — + # send the user to their case instead of the legacy pages + before_action :redirect_to_open_manual_case, except: [ :status_check ] steps :document @@ -162,6 +165,7 @@ def set_identity @identity = current_identity end + def on_verification_success track_event("verification.submitted", verification_type: "document", scenario: analytics_scenario_for(@identity)) flash[:success] = "Your documents have been submitted for review! We'll email you when they're processed." diff --git a/app/controllers/webhooks/calcom_controller.rb b/app/controllers/webhooks/calcom_controller.rb new file mode 100644 index 00000000..0bc5e6b1 --- /dev/null +++ b/app/controllers/webhooks/calcom_controller.rb @@ -0,0 +1,76 @@ +module Webhooks + # self-hosted cal.com webhooks the booking back into the case record. + # signature: HMAC-SHA256 of the raw payload in X-Cal-Signature-256. + class CalcomController < Webhooks::ApplicationController + before_action :verify_signature! + + HANDLED_EVENTS = %w[BOOKING_CREATED BOOKING_RESCHEDULED BOOKING_CANCELLED BOOKING_NO_SHOW_UPDATED].freeze + + def create + return head(:bad_request) unless parsed_body + + event = parsed_body[:triggerEvent] + return head(:ok) unless HANDLED_EVENTS.include?(event) + + payload = parsed_body[:payload] || {} + verification_case = find_case(payload) + return head(:ok) unless verification_case + + Calcom::ProcessBookingEventJob.perform_later( + event: event, + case_id: verification_case.id, + booking_uid: booking_uid(payload), + starts_at: payload[:startTime], + no_show: Array(payload[:attendees]).any? { |a| a[:noShow] } + ) + + head :ok + end + + private + + # no-show payloads use bookingUid; booking payloads use uid + def booking_uid(payload) = payload[:uid] || payload[:bookingUid] + + def find_case(payload) + case_public_id = payload.dig(:metadata, :casePublicId) + found = VerificationCase.find_by_public_id(case_public_id) if case_public_id.present? + return found if found + + # no-show events carry no metadata — match the stored booking + uid = booking_uid(payload) + found = VerificationCase.open_cases.find_by(booking_uid: uid) if uid.present? + return found if found + + # fallback: match on attendee email for bookings made without metadata + emails = Array(payload[:attendees]).filter_map { |a| a[:email] } + return nil if emails.empty? + + VerificationCase.open_cases.joins(:identity) + .where(identities: { primary_email: emails }) + .order(created_at: :desc).first + end + + def verify_signature! + secret = ENV["CALCOM_WEBHOOK_SECRET"] + return head(:service_unavailable) if secret.blank? + + signature = request.headers["X-Cal-Signature-256"] + return head(:unauthorized) if signature.blank? + + expected = OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post) + unless ActiveSupport::SecurityUtils.secure_compare(signature, expected) + Sentry.capture_message("Cal.com webhook signature mismatch", + level: :warning, tags: { component: "calcom" }, extra: { ip: request.remote_ip }) + head(:unauthorized) + end + end + + def parsed_body + return @parsed_body if defined?(@parsed_body) + @parsed_body = JSON.parse(request.raw_post, symbolize_names: true) + rescue JSON::ParserError + @parsed_body = nil + end + end +end diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index d17d6e4e..2e33ac9f 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -3,6 +3,7 @@ import "../js/lightswitch.js"; import "../js/click-to-copy"; import "../js/otp-input.js"; import "../js/persona-verify.js"; +import "../js/camera-capture.js"; import htmx from "htmx.org" window.htmx = htmx diff --git a/app/frontend/js/camera-capture.js b/app/frontend/js/camera-capture.js new file mode 100644 index 00000000..5e009603 --- /dev/null +++ b/app/frontend/js/camera-capture.js @@ -0,0 +1,99 @@ +// live capture for skip-persona manual verification cases — the document +// photo and the selfie both come straight from the device camera, no file +// picker. supports multiple widgets per page; submit unlocks only once +// every widget has a capture. +document.addEventListener("DOMContentLoaded", () => { + const widgets = Array.from(document.querySelectorAll("[data-camera-capture]")); + if (!widgets.length) return; + + const submitBtn = document.querySelector("[data-camera-submit]"); + + const updateSubmit = () => { + if (!submitBtn) return; + submitBtn.disabled = !widgets.every( + (widget) => widget.querySelector("[data-camera-input]").files.length > 0 + ); + }; + + widgets.forEach((el) => { + const input = el.querySelector("[data-camera-input]"); + const video = el.querySelector("[data-camera-video]"); + const canvas = el.querySelector("[data-camera-canvas]"); + const message = el.querySelector("[data-camera-message]"); + const openBtn = el.querySelector("[data-camera-open]"); + const takeBtn = el.querySelector("[data-camera-take]"); + const retakeBtn = el.querySelector("[data-camera-retake]"); + const facingMode = el.dataset.cameraFacing || "environment"; + const filename = el.dataset.cameraFilename || "capture.jpg"; + + let stream = null; + + const stopStream = () => { + if (stream) stream.getTracks().forEach((track) => track.stop()); + stream = null; + }; + + const show = (elm, visible) => { + if (elm) elm.style.display = visible ? "" : "none"; + }; + + openBtn.addEventListener("click", async () => { + if (!navigator.mediaDevices?.getUserMedia) { + message.textContent = "This browser can't access a camera — open this page on your phone instead."; + return; + } + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode, width: { ideal: 1920 } }, + audio: false, + }); + } catch (err) { + console.error("[camera-capture]", err); + message.textContent = "Camera access was blocked. Allow camera access and try again, or open this page on your phone."; + return; + } + video.srcObject = stream; + show(video, true); + show(canvas, false); + show(openBtn, false); + show(takeBtn, true); + show(retakeBtn, false); + message.textContent = + facingMode === "user" + ? "Look at the camera, then take the photo." + : "Line your document up in the frame, then take the photo."; + }); + + takeBtn.addEventListener("click", () => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext("2d").drawImage(video, 0, 0); + canvas.toBlob( + (blob) => { + if (!blob) return; + const file = new File([blob], filename, { type: "image/jpeg" }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + input.files = dataTransfer.files; + stopStream(); + show(video, false); + show(canvas, true); + show(takeBtn, false); + show(retakeBtn, true); + updateSubmit(); + message.textContent = "Looking good? If it's blurry or cut off, retake it."; + }, + "image/jpeg", + 0.92 + ); + }); + + retakeBtn.addEventListener("click", () => { + input.value = ""; + updateSubmit(); + openBtn.click(); + }); + + window.addEventListener("pagehide", stopStream); + }); +}); diff --git a/app/frontend/stylesheets/snippets/verifications.scss b/app/frontend/stylesheets/snippets/verifications.scss index 47813a87..24b3a498 100644 --- a/app/frontend/stylesheets/snippets/verifications.scss +++ b/app/frontend/stylesheets/snippets/verifications.scss @@ -625,3 +625,159 @@ } } } + +// -- manual verification call: tier selection cards -------------------- + +.tier-options { + display: flex; + flex-direction: column; + gap: $space-2; + margin-bottom: $space-4; + + // pico sets label:has([type=radio]) { width: fit-content } at higher + // specificity, which sizes each card to its text — force full width + .tier-option { + width: 100%; + } +} + +.tier-option { + display: flex; + align-items: flex-start; + gap: $space-3; + padding: $space-3 $space-4; + margin: 0; + border: 1px solid var(--surface-2-border); + border-radius: $radius-lg; + cursor: pointer; + transition: border-color $transition-fast, background $transition-fast; + + // the native radio is present for a11y/forms but drawn by the card + input[type="radio"] { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + &:hover { + border-color: var(--text-muted-strong); + } + + &:has(input:checked) { + border-color: var(--pico-primary); + background: color-mix(in srgb, var(--pico-primary) 5%, transparent); + + .tier-option-icon { + background: color-mix(in srgb, var(--pico-primary) 12%, transparent); + border-color: color-mix(in srgb, var(--pico-primary) 30%, transparent); + color: var(--pico-primary); + } + + .tier-option-radio::after { + opacity: 1; + transform: scale(1); + } + } + + &:has(input:focus-visible) { + outline: 2px solid var(--pico-primary); + outline-offset: 2px; + } +} + +.tier-option-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: $radius-md; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-2); + border: 1px solid var(--surface-2-border); + color: var(--text-muted-strong); + transition: background $transition-fast, border-color $transition-fast, color $transition-fast; + + @include dark-mode { + background: #2a2e38; + border-color: #3a3e48; + } +} + +.tier-option-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.125rem; + + strong { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-strong); + } + + span { + font-size: 0.85rem; + color: var(--text-muted-strong); + line-height: 1.4; + } +} + +.tier-option-radio { + flex-shrink: 0; + width: 20px; + height: 20px; + margin-top: 2px; + border-radius: 50%; + border: 2px solid var(--surface-2-border); + display: flex; + align-items: center; + justify-content: center; + transition: border-color $transition-fast; + + &::after { + content: ""; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--pico-primary); + opacity: 0; + transform: scale(0.5); + transition: opacity $transition-fast, transform $transition-fast; + } +} + +.tier-option:has(input:checked) .tier-option-radio { + border-color: var(--pico-primary); +} + +// tier B follow-up questions, revealed by CSS when that card is picked +.tier-b-fields { + display: none; + flex-direction: column; + gap: $space-2; + padding: $space-3 $space-4; + margin-bottom: $space-4; + border-radius: $radius-md; + background: var(--surface-2); + border: 1px solid var(--surface-2-border); + + label { + font-size: 0.85rem; + color: var(--text-muted-strong); + margin-bottom: 0; + } + + select, input[type="text"] { + margin-bottom: 0; + } +} + +form:has(.tier-options input[value="alternative"]:checked) .tier-b-fields { + display: flex; +} diff --git a/app/jobs/calcom/process_booking_event_job.rb b/app/jobs/calcom/process_booking_event_job.rb new file mode 100644 index 00000000..af28900d --- /dev/null +++ b/app/jobs/calcom/process_booking_event_job.rb @@ -0,0 +1,34 @@ +class Calcom::ProcessBookingEventJob < ApplicationJob + queue_as :default + + def perform(event:, case_id:, booking_uid:, starts_at:, no_show: false) + verification_case = VerificationCase.find_by(id: case_id) + return unless verification_case + + case event + when "BOOKING_CREATED", "BOOKING_RESCHEDULED" + verification_case.update!(booking_uid: booking_uid, call_starts_at: starts_at&.to_time) + verification_case.schedule_call! unless verification_case.call_scheduled? + verification_case.log_event!(:call_booked, data: { event: event, booking_uid: booking_uid, starts_at: starts_at }) + VerificationCaseMailer.call_scheduled(verification_case).deliver_later + when "BOOKING_CANCELLED" + # cal.com emails the attendee about the cancellation (with a rebook + # link) — we only put the case back so our status page stays truthful + verification_case.unschedule_call! if verification_case.call_scheduled? + verification_case.update!(booking_uid: nil, call_starts_at: nil) + verification_case.log_event!(:call_cancelled, data: { booking_uid: booking_uid }) + when "BOOKING_NO_SHOW_UPDATED" + # only when the host MARKS a no-show — un-marking changes nothing here. + # the booking is spent, so the case reopens for booking like a + # cancellation, and the audit trail makes repeat no-shows visible + # (staff can deny with the existing no_show rejection reason). + return unless no_show + + verification_case.unschedule_call! if verification_case.call_scheduled? + verification_case.update!(booking_uid: nil, call_starts_at: nil) + verification_case.log_event!(:call_no_show, data: { booking_uid: booking_uid }) + end + rescue AASM::InvalidTransition + Rails.logger.info("[Calcom] Ignoring #{event} for case #{case_id} in state #{verification_case.status}") + end +end diff --git a/app/jobs/persona/process_inquiry_event_job.rb b/app/jobs/persona/process_inquiry_event_job.rb index 99158dc2..a3009c63 100644 --- a/app/jobs/persona/process_inquiry_event_job.rb +++ b/app/jobs/persona/process_inquiry_event_job.rb @@ -4,7 +4,12 @@ class Persona::ProcessInquiryEventJob < ApplicationJob def perform(event_name:, inquiry_id:) @verification = Verification.find_by(persona_inquiry_id: inquiry_id) unless @verification - Rails.logger.info("[Persona] No verification found for inquiry #{inquiry_id} — may have been nuked") + # capture-only inquiries belong to manual verification cases, not verifications + if (verification_case = VerificationCase.find_by(persona_inquiry_id: inquiry_id)) + handle_case_event(verification_case, event_name, inquiry_id) + else + Rails.logger.info("[Persona] No verification found for inquiry #{inquiry_id} — may have been nuked") + end return end @identity = @verification.identity @@ -34,6 +39,54 @@ def perform(event_name:, inquiry_id:) private + # manual verification case capture inquiries: no decisioning, just + # snapshot the signals + pull the captured docs onto the case. + def handle_case_event(verification_case, event_name, inquiry_id) + Sentry.set_tags(component: "persona", event: event_name) + Sentry.set_extras(inquiry_id: inquiry_id, verification_case_id: verification_case.id) + + case event_name + when "inquiry.completed", "inquiry.approved" + return if verification_case.docs_submitted? || !verification_case.link_sent? + + inquiry = Persona.instance.retrieve_inquiry(inquiry_id) + + photos = Persona::PhotoSet.empty + (inquiry.document_ids + inquiry.verification_ids).each do |ref| + photos += ref[:type].to_s.include?("document") ? + Persona.instance.retrieve_document_photos(ref[:id], type: ref[:type]) : + Persona.instance.retrieve_verification_photos(ref[:id], type: ref[:type]) + rescue Persona::APIError => e + Sentry.capture_exception(e) + end + + downloaded = download_photos(photos) + + ActiveRecord::Base.transaction do + verification_case.update!(persona_signal_snapshot: { + inquiry: inquiry.raw, + sessions: inquiry.sessions, + behaviors: inquiry.behaviors, + network_signals: build_network_signals(inquiry.sessions) + }.as_json) + + (downloaded || []).each do |dl| + doc = verification_case.documents.new(document_kind: "persona_capture", source: "persona") + doc.file.attach(io: StringIO.new(dl[:bytes]), filename: dl[:filename], content_type: "image/jpeg") + doc.save! + end + + verification_case.submit_docs! + end + + verification_case.log_event!(:docs_submitted, data: { source: "persona", inquiry_id: inquiry_id }) + when "inquiry.failed", "inquiry.expired", "inquiry.declined" + verification_case.log_event!(:capture_inquiry_ended, data: { event: event_name, inquiry_id: inquiry_id }) + end + rescue AASM::InvalidTransition + Rails.logger.info("[Persona] Ignoring duplicate #{event_name} for case inquiry #{inquiry_id}") + end + def handle_completed(inquiry_id) return if @verification.pending? || @verification.approved? diff --git a/app/mailers/verification_case_mailer.rb b/app/mailers/verification_case_mailer.rb new file mode 100644 index 00000000..277a10cc --- /dev/null +++ b/app/mailers/verification_case_mailer.rb @@ -0,0 +1,32 @@ +class VerificationCaseMailer < ApplicationMailer + default from: ApplicationMailer::IDENTITY_FROM + + def invitation(verification_case, token) + @case = verification_case + @identity = verification_case.identity + @first_name = @identity.first_name + @link = manual_verification_url(token: token) + @expires_at = verification_case.access_token_expires_at + @env_prefix = env_prefix + @preview_text = "Your manual verification link from Hack Club" + + mail( + to: @identity.primary_email, + subject: prefixed_subject("Your manual identity verification link") + ) + end + + def call_scheduled(verification_case) + @case = verification_case + @identity = verification_case.identity + @first_name = @identity.first_name + @starts_at = verification_case.call_starts_at + @env_prefix = env_prefix + @preview_text = "Your verification call is booked" + + mail( + to: @identity.primary_email, + subject: prefixed_subject("Your verification call is booked") + ) + end +end diff --git a/app/models/identity.rb b/app/models/identity.rb index 568b245f..93aae865 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -73,6 +73,7 @@ def active_for_backend? = backend_user&.active? has_many :vouch_verifications, class_name: "Verification::VouchVerification", dependent: :destroy has_many :persona_verifications, class_name: "Verification::PersonaVerification", dependent: :destroy has_many :persona_student_id_verifications, class_name: "Verification::PersonaStudentIdVerification", dependent: :destroy + has_many :verification_cases, class_name: "VerificationCase", dependent: :destroy has_many :addresses, class_name: "Address", dependent: :destroy belongs_to :primary_address, class_name: "Address", optional: true diff --git a/app/models/verification/manual_verification_call.rb b/app/models/verification/manual_verification_call.rb new file mode 100644 index 00000000..a86ce412 --- /dev/null +++ b/app/models/verification/manual_verification_call.rb @@ -0,0 +1,84 @@ +# the durable outcome of a manual verification call — created at +# decision time from a VerificationCase. this is the record that +# the durable decision record alongside the raw evidence: reviewer, +# checklist, signal snapshot pointer, and (for tier B) an expiry. +class Verification::ManualVerificationCall < Verification + include Verification::Rejectable + + belongs_to :reviewer, class_name: "Backend::User", optional: true + belongs_to :sample_reviewer, class_name: "Backend::User", optional: true + has_one :verification_case, foreign_key: :verification_id + + # every item the reviewer works through on the call, stored as jsonb. + # confidence + notes ride alongside the y/n answers. + CHECKLIST_ITEMS = { + "doc_matches_live_face" => "Document photo matches live face", + "doc_matches_selfie" => "Document photo matches selfie (persona or live capture)", + "name_dob_consistent" => "Name/DOB consistent with account records", + "signals_clean" => "Signals clean (no virtual camera, geo plausible)", + "doc_unaltered" => "Document appears unaltered" + }.freeze + + CONFIDENCE_LEVELS = %w[high medium low].freeze + + validates :reviewer, presence: true + validate :checklist_complete, if: -> { approved? || rejected? } + + rejection_reasons( + identity_not_confirmed: { name: "Could not confirm identity on the call", fatal: false }, + docs_insufficient: { name: "Documents insufficient or unreadable", fatal: false }, + no_show: { name: "Did not attend the scheduled call", fatal: false }, + other: { name: "Other fixable issue", fatal: false }, + info_mismatch: { name: "Information doesn't match profile", fatal: true }, + altered: { name: "Document appears altered/fraudulent", fatal: true }, + duplicate: { name: "This identity is a duplicate", fatal: true }, + fraud: { name: "Fraudulent submission", fatal: true } + ) + + aasm column: :status, timestamps: true, whiny_transitions: true, whiny_persistence: true do + state :pending, initial: true + state :approved + state :rejected + + event :approve do + transitions from: :pending, to: :approved + end + + event :mark_as_rejected do + transitions from: :pending, to: :rejected + before { |reason, details| set_rejection_fields(reason, details) } + after { notify_rejection } + end + end + + def confidence = checklist&.dig("confidence") + def reviewer_notes = checklist&.dig("notes") + + def checklist_answer(item) = checklist&.dig(item) + + # tier A approvals never expire; tier B gets a 12-month backstop. + # nullable by design so the policy can change without a migration. + def expired? = expires_at.present? && expires_at.past? + + + # polymorphic interface + def document_type_label = "Manual verification call" + def review_info_partial = "backend/verifications/review_manual_call_info" + def review_full_partial = "backend/verifications/review_manual_call_full" + def relevant_record = verification_case + def needs_break_glass? = false + def auto_break_glass_reason = nil + def status_pending_partial = "verifications/status/pending_document" + def auto_approvable? = false + + private + + def checklist_complete + missing = CHECKLIST_ITEMS.keys.reject { |k| checklist&.key?(k) } + errors.add(:checklist, "is missing answers: #{missing.join(', ')}") if missing.any? + + unless CONFIDENCE_LEVELS.include?(checklist&.dig("confidence")) + errors.add(:checklist, "must record a confidence level") + end + end +end diff --git a/app/models/verification_case.rb b/app/models/verification_case.rb new file mode 100644 index 00000000..be306750 --- /dev/null +++ b/app/models/verification_case.rb @@ -0,0 +1,196 @@ +# a manual verification call case — the container for the whole +# journey from "persona failed me, help" to a human decision. +# +# the case is workflow state; the durable outcome lives on a +# Verification::ManualVerificationCall created at decision time. +class VerificationCase < ApplicationRecord + acts_as_paranoid + + include AASM + include PublicActivity::Model + + has_paper_trail + + include PublicIdentifiable + set_public_id_prefix "vcase" + + FLIPPER_FLAG = :manual_verification_call_2026_07_03 + ACCESS_TOKEN_TTL = 7.days + # alternative-docs approvals expire; gov-id manual approvals don't (same + # document class as a persona-verified ID — only the extraction path differed) + ALTERNATIVE_DOCS_EXPIRY = 12.months + + belongs_to :identity + belongs_to :opened_by, class_name: "Backend::User", optional: true + belongs_to :verification, optional: true + has_many :documents, class_name: "VerificationCase::Document", dependent: :destroy + has_many :events, class_name: "VerificationCase::Event", dependent: :destroy + has_many :comments, class_name: "VerificationCase::Comment", dependent: :destroy + + encrypts :persona_session_token + + # which kind of document backs this case — a government document (persona + # rejected it but a human can read it) or alternative documents (transcript, + # report card, school letter). NOT the org-wide verification tiers. + enum :document_class, { government_id: "government_id", alternative: "alternative" } + + ALTERNATIVE_REASONS = { + "no_government_id" => "I don't have any government-issued ID", + "id_inaccessible" => "My ID exists but I can't access it right now", + "guardian_refusal" => "My parent/guardian holds my documents", + "other" => "Other (please explain)" + }.freeze + + validates :alternative_reason, inclusion: { in: ALTERNATIVE_REASONS.keys }, if: :alternative? + validates :alternative_reason_details, presence: true, if: -> { alternative? && alternative_reason == "other" } + validates :persona_inquiry_id, uniqueness: { allow_nil: true, conditions: -> { where(deleted_at: nil) } } + + scope :open_cases, -> { where.not(status: %w[approved denied]) } + + alias_method :to_param, :public_id + + aasm column: :status, timestamps: true, whiny_transitions: true, whiny_persistence: true do + state :requested, initial: true + state :link_sent + state :docs_submitted + state :call_scheduled + state :call_held + state :approved + state :denied + + event :send_link do + transitions from: [ :requested, :link_sent ], to: :link_sent + end + + event :submit_docs do + transitions from: [ :link_sent, :docs_submitted ], to: :docs_submitted + end + + event :schedule_call do + transitions from: [ :docs_submitted, :call_scheduled ], to: :call_scheduled + end + + # booking cancelled without a rebook — back to "book your call" + event :unschedule_call do + transitions from: :call_scheduled, to: :docs_submitted + end + + event :hold_call do + transitions from: :call_scheduled, to: :call_held + end + + event :approve do + transitions from: :call_held, to: :approved + after { close_out! } + end + + event :deny do + transitions from: :call_held, to: :denied + after { close_out! } + end + end + + def open? = !approved? && !denied? + def decided? = approved? || denied? + + # -- feature flag ------------------------------------------------------ + + def enable_flag! = Flipper.enable(FLIPPER_FLAG, identity) + def revoke_flag! = Flipper.disable(FLIPPER_FLAG, identity) + + # -- single-use access link (mirrors Identity::V2LoginCode) ------------- + + def generate_access_token! + update!( + access_token: SecureRandom.urlsafe_base64(32), + access_token_expires_at: ACCESS_TOKEN_TTL.from_now, + access_token_used_at: nil + ) + access_token + end + + # atomic single-use consume — the update_all guarded on used_at: nil + # means two racing requests can't both win. + def consume_access_token!(token) + return false if token.blank? || access_token.blank? + return false unless ActiveSupport::SecurityUtils.secure_compare(token, access_token) + return false if access_token_expires_at.nil? || access_token_expires_at.past? + + self.class.where(id: id, access_token_used_at: nil) + .update_all(access_token_used_at: Time.current) == 1 + end + + # -- persona capture-only inquiry --------------------------------------- + + # staff can open a case that avoids persona entirely (the "i don't want + # to use persona" crowd) — those cases go straight to camera upload + def persona_capture_available? = !skip_persona? && capture_template_id.present? + + # the reviewer can only tick "document matches selfie" if there is a + # selfie: either inside the persona capture or taken live on our page + def selfie_available? = persona_inquiry_id.present? || documents.where(document_kind: "selfie").exists? + + def generate_capture_inquiry! + raise "this case already has an inquiry!" if persona_inquiry_id.present? + + return nil unless persona_capture_available? # skip-persona case or no template — camera upload instead + + inquiry = Persona.instance.create_inquiry( + template_id: capture_template_id, + account_reference_id: identity.public_id, + fields: { + "name-first": identity.legal_first_name.presence || identity.first_name, + "name-last": identity.legal_last_name.presence || identity.last_name, + "email-address": identity.primary_email + }.compact + ) + + update!(persona_inquiry_id: inquiry.id, persona_session_token: inquiry.session_token) + inquiry + end + + # one capture-only template serves both document classes — the template + # accepts arbitrary documents, and the selfie/liveness step applies either way + def capture_template_id + return nil if document_class.blank? + + creds = Rails.application.credentials.persona + template = creds.respond_to?(:manual_capture_template) ? creds.manual_capture_template : nil + template.presence || ENV["PERSONA_MANUAL_CAPTURE_TEMPLATE"].presence + end + + # -- booking gate -------------------------------------------------------- + + # cal.com link only revealed once docs are in + def booking_available? = docs_submitted? + + def booking_url + base = ENV["CALCOM_MANUAL_VERIFICATION_BOOKING_URL"] + return nil if base.blank? + + uri = URI.parse(base) + params = URI.decode_www_form(uri.query || "") + params << [ "metadata[casePublicId]", public_id ] + params << [ "email", identity.primary_email ] + uri.query = URI.encode_www_form(params) + uri.to_s + end + + # -- audit log ------------------------------------------------------------- + + def log_event!(key, actor: nil, data: {}, request: nil) + events.create!( + key: key.to_s, + actor: actor, + data: data, + ip_address: request&.remote_ip, + user_agent: request&.user_agent + ) + end + + private + + # decision time: drop the flag. documents are retained (break-glass + # gated) — the ManualVerificationCall itself is created by the decision flow. + def close_out! = revoke_flag! +end diff --git a/app/models/verification_case/comment.rb b/app/models/verification_case/comment.rb new file mode 100644 index 00000000..7a8b137f --- /dev/null +++ b/app/models/verification_case/comment.rb @@ -0,0 +1,12 @@ +# staff discussion on a case, replacing the old slack thread — reviewer +# notes, second opinions, anything that isn't a formal audit event. +class VerificationCase::Comment < ApplicationRecord + self.table_name = "verification_case_comments" + + belongs_to :verification_case + belongs_to :author, class_name: "Backend::User" + + validates :body, presence: true, length: { maximum: 5_000 } + + scope :chronological, -> { order(created_at: :asc) } +end diff --git a/app/models/verification_case/document.rb b/app/models/verification_case/document.rb new file mode 100644 index 00000000..7f0d8a27 --- /dev/null +++ b/app/models/verification_case/document.rb @@ -0,0 +1,45 @@ +# a raw evidence artifact on a case: user-submitted doc, persona capture, +# or the call recording. all of it lands in encrypted storage; access +# goes through break-glass and is logged. +class VerificationCase::Document < ApplicationRecord + self.table_name = "verification_case_documents" + + acts_as_paranoid + + belongs_to :verification_case + has_one_attached :file + has_many :break_glass_records, as: :break_glassable, class_name: "BreakGlassRecord", dependent: :destroy + + # BreakGlassRecord's activity tracking resolves its recipient via + # break_glassable.identity — ours lives on the case + delegate :identity, to: :verification_case + + DOCUMENT_KINDS = { + "primary_doc" => "Primary document", + "corroborating_doc" => "Corroborating document", + "persona_capture" => "Persona capture", + "selfie" => "Selfie", + "call_recording" => "Call recording" + }.freeze + + enum :document_kind, DOCUMENT_KINDS.keys.index_by(&:itself) + enum :source, %w[persona direct_upload call_recording].index_by(&:itself), prefix: :from + + validates :file, presence: true + validate :file_size_and_type + + def kind_label = DOCUMENT_KINDS[document_kind] + + private + + def file_size_and_type + return unless file.attached? + + errors.add(:file, "is too large (maximum is 100MB)") if file.byte_size > 100.megabytes + + allowed = %w[image/jpeg image/png image/jpg image/heic image/heif application/pdf video/mp4 video/webm audio/mpeg] + unless file.content_type.in?(allowed) + errors.add(:file, "must be a JPEG, PNG, HEIC, PDF, or recording file") + end + end +end diff --git a/app/models/verification_case/event.rb b/app/models/verification_case/event.rb new file mode 100644 index 00000000..e315ca3b --- /dev/null +++ b/app/models/verification_case/event.rb @@ -0,0 +1,18 @@ +# append-only audit trail for a case. every state transition, every +# document view, every decision — the break-glass requirement means +# reads get logged too, so this table only ever grows. +class VerificationCase::Event < ApplicationRecord + self.table_name = "verification_case_events" + + belongs_to :verification_case + belongs_to :actor, polymorphic: true, optional: true + + validates :key, presence: true + + # append-only: rows can be created, never mutated or deleted + def readonly? = persisted? + + before_destroy { raise ActiveRecord::ReadOnlyRecord } + + scope :recent_first, -> { order(created_at: :desc) } +end diff --git a/app/policies/verification_case_policy.rb b/app/policies/verification_case_policy.rb new file mode 100644 index 00000000..a63b283c --- /dev/null +++ b/app/policies/verification_case_policy.rb @@ -0,0 +1,15 @@ +class VerificationCasePolicy < ApplicationPolicy + def index? = user_is_manual_document_verifier? + + def show? = user_is_manual_document_verifier? + + def create? = user_is_manual_document_verifier? + + def resend_link? = user_is_manual_document_verifier? + + def hold_call? = user_is_manual_document_verifier? + + def comment? = user_is_manual_document_verifier? + + def decide? = user_is_manual_document_verifier? +end diff --git a/app/services/deletion_service.rb b/app/services/deletion_service.rb index 717558a5..65fcbdce 100644 --- a/app/services/deletion_service.rb +++ b/app/services/deletion_service.rb @@ -258,6 +258,11 @@ def self.collect_attachments(identity) identity.vouch_verifications.with_deleted.each do |vv| blobs << vv.evidence.blob if vv.evidence.attached? end + identity.verification_cases.with_deleted.each do |kase| + kase.documents.with_deleted.each do |doc| + blobs << doc.file.blob if doc.file.attached? + end + end blobs.compact end @@ -268,6 +273,11 @@ def self.detach_attachments(identity) identity.vouch_verifications.with_deleted.each do |vv| vv.evidence.detach if vv.evidence.attached? end + identity.verification_cases.with_deleted.each do |kase| + kase.documents.with_deleted.each do |doc| + doc.file.detach if doc.file.attached? + end + end end def self.purge_attachments(identity) diff --git a/app/views/backend/identities/show.html.erb b/app/views/backend/identities/show.html.erb index 1515992a..f1e7367a 100644 --- a/app/views/backend/identities/show.html.erb +++ b/app/views/backend/identities/show.html.erb @@ -238,36 +238,60 @@ <% end %> <%# === Verifications === %> - <% if @verifications.any? %> -
-| type | status | submitted | reason | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Manual call case | +<%= kase.status.humanize.downcase %> | +<%= kase.created_at.strftime("%b %d, %Y") %> | +<%= kase.document_class.present? ? kase.document_class.humanize.downcase : "—" %> | +<%= link_to "view →", backend_verification_case_path(kase) %> | +||||||||||||||||||||||||
diff --git a/app/views/backend/verification_cases/index.html.erb b/app/views/backend/verification_cases/index.html.erb
new file mode 100644
index 00000000..d3b7aa16
--- /dev/null
+++ b/app/views/backend/verification_cases/index.html.erb
@@ -0,0 +1,52 @@
+<% content_for :title, "Manual Verification Cases" %>
+manual verification cases (<%= @open_cases.total_count %> open)+
<%= @case.public_id %>+
+
+ <% end %>
+
+ <% if @case.call_starts_at.present? %>
+
+ persona signal snapshot+<%= JSON.pretty_generate(@case.persona_signal_snapshot) %>+
+
+
+Scan your document+This uses your camera to capture your document and a quick selfie. +
+
diff --git a/app/views/manual_verifications/link_invalid.html.erb b/app/views/manual_verifications/link_invalid.html.erb
new file mode 100644
index 00000000..09ca6b2a
--- /dev/null
+++ b/app/views/manual_verifications/link_invalid.html.erb
@@ -0,0 +1,10 @@
+
+
+
+ Ready when you are+Have your document handy. The scan takes about two minutes. +
+
+
+ <%= link_to "Upload directly instead", manual_verification_path, class: "secondary" %>
+
+
+
+
+This link isn't valid+Your manual verification link may have expired, already been used, or belongs to a different account. +
+
diff --git a/app/views/manual_verifications/show.html.erb b/app/views/manual_verifications/show.html.erb
new file mode 100644
index 00000000..2f820559
--- /dev/null
+++ b/app/views/manual_verifications/show.html.erb
@@ -0,0 +1,234 @@
+Reply to the email we sent you (or write to identity@hackclub.com) and we'll send a fresh one. +
+
+
+Manual identity verification+A few steps, then a short video call with our team. +
+ <% if @case.decided? %>
+
diff --git a/app/views/verification_case_mailer/call_scheduled.html.erb b/app/views/verification_case_mailer/call_scheduled.html.erb
new file mode 100644
index 00000000..19b9826e
--- /dev/null
+++ b/app/views/verification_case_mailer/call_scheduled.html.erb
@@ -0,0 +1,9 @@
+
+ "><%= @case.approved? ? "approved" : "not approved" %>
+ <% if @case.approved? %>
+
+ You're verified!+Your identity was confirmed on the call. You're all set — nothing else to do here. + <% else %> +We couldn't verify your identity+Check your email for details. If you think this is a mistake, reply to that email. + <% end %> +
+ reviewing
+
+ Your call is done+We're finalizing the decision — you'll get an email shortly. +
+ call booked
+
+ See you on the call+ <% if @case.call_starts_at %> +Booked for <%= @case.call_starts_at.strftime("%A, %B %-d at %H:%M %Z") %>. Have your document with you. + <% else %> +Your booking is confirmed. Have your document with you. + <% end %> +Reminder: the call is recorded. Details are in your booking confirmation email. +Documents received — book your call+Last step: a short video call with a member of our team to confirm you match your document. + + <% if @case.recording_consent_acknowledged? %> + <% if @case.booking_url %> + <%= link_to "Book your call", @case.booking_url, role: "button", class: "chooser-cta" %> + <% else %> +Booking isn't set up yet — we'll email you a scheduling link shortly. + <% end %> + <% else %> +Before you book: verification calls are recorded. We record so a decision can be re-reviewed later without asking you to repeat this process. Recordings are stored encrypted and every access to them is logged. + <%= form_with url: manual_verification_recording_ack_path, method: :post, local: true do %> + + <% end %> + <% end %> +One more thing before you continue+Almost any government-issued document works — a passport, national ID, driver's license or permit, or residency card, even if Persona rejected it. A government document makes your verification permanent; other documents mean we'll need to re-verify you in a year. +Sure you don't have one? + <%= form_with url: manual_verification_document_class_path, method: :post, local: true do %> + + + + +
+ <%= link_to "I'll use a government ID", manual_verification_path, role: "button" %>
+
+
+ <% end %>
+ Which describes you?+Both paths end the same way — a quick document check and a short video call. Pick whichever fits. + <%= form_with url: manual_verification_document_class_path, method: :post, local: true do %> + + +
+
+
+
+
+
+
+
+ <% end %>
+ Submit your document+ <% if @case.alternative? %> +A transcript, report card, or letter from your school all work. + <% end %> + <% if @case.persona_capture_available? %> +Scan your document with your camera — this also captures a quick selfie for the call reviewer to compare against. + <%= link_to "Scan with your camera", manual_verification_capture_path, role: "button", class: "chooser-cta" %> +Camera not working? Upload directly below. + <% elsif @case.skip_persona? %> +Take a photo of your document and a selfie with your camera. On the call, the reviewer will compare them against you directly. + <% elsif @case.government_id? %> +Upload a clear photo or scan of your government document. On the call, the reviewer will compare it against you directly. + <% end %> + + <%= form_with url: manual_verification_documents_path, method: :post, local: true, multipart: true do %> + <% if @case.skip_persona? %> + + + + <% else %> + + <% end %> + + <%# prefilled from the account — editable because the document may + differ from what we have on file, and that difference is exactly + what the reviewer needs to see %> + + + + + + + + + + <% if @case.skip_persona? %> + + <% else %> + + <% end %> + <% end %> +What you've submitted+
Hey <%= @first_name %>, + +Your identity verification call is booked<% if @starts_at %> for <%= @starts_at.strftime("%A, %B %-d at %H:%M %Z") %><% end %>. + +This call is recorded. We record so a decision can be re-reviewed later without asking you to do this again. The recording is stored encrypted and every access to it is logged. + +Have your document with you on the call — the reviewer will compare it against what you submitted. + +— The Hack Club team diff --git a/app/views/verification_case_mailer/call_scheduled.text.erb b/app/views/verification_case_mailer/call_scheduled.text.erb new file mode 100644 index 00000000..1e59fc6f --- /dev/null +++ b/app/views/verification_case_mailer/call_scheduled.text.erb @@ -0,0 +1,9 @@ +Hey <%= @first_name %>, + +Your identity verification call is booked<% if @starts_at %> for <%= @starts_at.strftime("%A, %B %-d at %H:%M %Z") %><% end %>. + +This call is recorded. We record so a decision can be re-reviewed later without asking you to do this again. The recording is stored encrypted and every access to it is logged. + +Have your document with you on the call — the reviewer will compare it against what you submitted. + +— The Hack Club team diff --git a/app/views/verification_case_mailer/invitation.html.erb b/app/views/verification_case_mailer/invitation.html.erb new file mode 100644 index 00000000..faf0f6ce --- /dev/null +++ b/app/views/verification_case_mailer/invitation.html.erb @@ -0,0 +1,11 @@ +Hey <%= @first_name %>, + +Thanks for reaching out about verifying your identity. We've set up a manual verification path for you — it takes a few minutes to submit your documents, then you'll book a short video call with a member of our team. + + + +This link works once and expires <%= @expires_at.strftime("%B %-d, %Y") %>. If it stops working, reply to this email and we'll send you a fresh one. + +If you didn't ask for this, you can ignore this email. + +— The Hack Club team diff --git a/app/views/verification_case_mailer/invitation.text.erb b/app/views/verification_case_mailer/invitation.text.erb new file mode 100644 index 00000000..58bf5c6d --- /dev/null +++ b/app/views/verification_case_mailer/invitation.text.erb @@ -0,0 +1,11 @@ +Hey <%= @first_name %>, + +Thanks for reaching out about verifying your identity. We've set up a manual verification path for you — it takes a few minutes to submit your documents, then you'll book a short video call with a member of our team. + +Start here: <%= @link %> + +This link works once and expires <%= @expires_at.strftime("%B %-d, %Y") %>. If it stops working, reply to this email and we'll send you a fresh one. + +If you didn't ask for this, you can ignore this email. + +— The Hack Club team diff --git a/config/flipper_features.yml b/config/flipper_features.yml index 0fe8d93d..1725805a 100644 --- a/config/flipper_features.yml +++ b/config/flipper_features.yml @@ -14,3 +14,4 @@ shared: disable_slack_invites_2025_12_08: suppresses Slack SCIM provisioning for an actor dev_force_manual_review_2026_05_27: forces verifications for an actor into manual review dev_force_deny_verification_2026_05_27: auto-denies verifications for an actor + manual_verification_call_2026_07_03: enables the manual verification call flow for an actor with an open case diff --git a/config/routes.rb b/config/routes.rb index fda93d23..d7ae8971 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -223,6 +223,15 @@ def self.matches?(request) end end + resources :verification_cases, only: [ :index, :show, :create ] do + member do + post :resend_link + post :comment + patch :hold_call + patch :decide + end + end + resources :identities do member do post :clear_slack_id @@ -320,6 +329,13 @@ def self.matches?(request) delete "/logout", to: "sessions#logout", as: :logout + # manual verification call flow (flipper-gated, single-use link entry) + get "/verifications/manual", to: "manual_verifications#show", as: :manual_verification + post "/verifications/manual/document_class", to: "manual_verifications#choose_document_class", as: :manual_verification_document_class + get "/verifications/manual/capture", to: "manual_verifications#start_capture", as: :manual_verification_capture + post "/verifications/manual/documents", to: "manual_verifications#submit_documents", as: :manual_verification_documents + post "/verifications/manual/recording_ack", to: "manual_verifications#acknowledge_recording", as: :manual_verification_recording_ack + get "/verifications/new", to: "verifications#new", as: :new_verifications get "/verifications/status", to: "verifications#status", as: :verification_status get "/verifications/status/check", to: "verifications#status_check", as: :verification_status_check @@ -436,6 +452,7 @@ def self.matches?(request) namespace :webhooks do post "persona", to: "persona#create" + post "calcom", to: "calcom#create" end scope :saml do diff --git a/db/migrate/20260806000001_create_verification_cases.rb b/db/migrate/20260806000001_create_verification_cases.rb new file mode 100644 index 00000000..a15ecd0d --- /dev/null +++ b/db/migrate/20260806000001_create_verification_cases.rb @@ -0,0 +1,86 @@ +class CreateVerificationCases < ActiveRecord::Migration[8.0] + def change + create_table :verification_cases do |t| + t.references :identity, null: false, foreign_key: true + t.references :opened_by, foreign_key: { to_table: :backend_users } + t.references :verification, foreign_key: true + + t.string :status, null: false + t.string :document_class + t.string :alternative_reason + t.text :alternative_reason_details + + t.string :persona_inquiry_id + t.text :persona_session_token + t.jsonb :persona_signal_snapshot + + t.string :access_token + t.datetime :access_token_expires_at + t.datetime :access_token_used_at + + t.boolean :skip_persona, default: false, null: false + + t.string :booking_uid + t.datetime :call_starts_at + + t.jsonb :submitted_fields, default: {}, null: false + + t.boolean :attested, default: false, null: false + t.boolean :biometric_consent, default: false, null: false + t.boolean :recording_consent_acknowledged, default: false, null: false + + # AASM timestamps + t.datetime :link_sent_at + t.datetime :docs_submitted_at + t.datetime :call_scheduled_at + t.datetime :call_held_at + t.datetime :approved_at + t.datetime :denied_at + + t.datetime :deleted_at + t.timestamps + end + + add_index :verification_cases, :status + add_index :verification_cases, :deleted_at + add_index :verification_cases, :persona_inquiry_id, unique: true, where: "persona_inquiry_id IS NOT NULL AND deleted_at IS NULL" + add_index :verification_cases, :access_token, unique: true, where: "access_token IS NOT NULL" + + create_table :verification_case_documents do |t| + t.references :verification_case, null: false, foreign_key: true + t.string :document_kind, null: false + t.string :source, null: false + t.datetime :deleted_at + t.timestamps + end + + add_index :verification_case_documents, :deleted_at + + create_table :verification_case_comments do |t| + t.references :verification_case, null: false, foreign_key: true + t.references :author, null: false, foreign_key: { to_table: :backend_users } + t.text :body, null: false + t.timestamps + end + + create_table :verification_case_events do |t| + t.references :verification_case, null: false, foreign_key: true + t.string :key, null: false + t.references :actor, polymorphic: true + t.jsonb :data, default: {}, null: false + t.string :ip_address + t.string :user_agent + t.datetime :created_at, null: false + end + + add_column :verifications, :reviewer_id, :bigint + add_column :verifications, :checklist, :jsonb + add_column :verifications, :expires_at, :datetime + add_column :verifications, :sampled_at, :datetime + add_column :verifications, :sample_reviewer_id, :bigint + + add_foreign_key :verifications, :backend_users, column: :reviewer_id + add_foreign_key :verifications, :backend_users, column: :sample_reviewer_id + add_index :verifications, :reviewer_id + end +end diff --git a/db/schema.rb b/db/schema.rb index fcb26e6f..1115797c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_31_000001) do +ActiveRecord::Schema[8.0].define(version: 2026_08_06_000001) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -450,6 +450,7 @@ t.datetime "last_step_up_at" t.string "last_step_up_action" t.index ["identity_id"], name: "index_identity_sessions_on_identity_id" + t.index ["session_token_bidx"], name: "index_identity_sessions_on_session_token_bidx", unique: true end create_table "identity_tombstone_collisions", force: :cascade do |t| @@ -483,6 +484,7 @@ t.datetime "updated_at", null: false t.bigint "login_attempt_id" t.datetime "invalidated_at" + t.string "purpose", default: "login" t.index ["identity_id", "login_attempt_id", "code", "used_at"], name: "index_v2_codes_on_identity_attempt_code_used" t.index ["identity_id"], name: "index_identity_v2_login_codes_on_identity_id" t.index ["login_attempt_id"], name: "index_identity_v2_login_codes_on_login_attempt_id" @@ -614,6 +616,79 @@ t.index ["slug"], name: "index_slack_idp_groups_on_slug", unique: true end + create_table "verification_case_comments", force: :cascade do |t| + t.bigint "verification_case_id", null: false + t.bigint "author_id", null: false + t.text "body", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["author_id"], name: "index_verification_case_comments_on_author_id" + t.index ["verification_case_id"], name: "index_verification_case_comments_on_verification_case_id" + end + + create_table "verification_case_documents", force: :cascade do |t| + t.bigint "verification_case_id", null: false + t.string "document_kind", null: false + t.string "source", null: false + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["deleted_at"], name: "index_verification_case_documents_on_deleted_at" + t.index ["verification_case_id"], name: "index_verification_case_documents_on_verification_case_id" + end + + create_table "verification_case_events", force: :cascade do |t| + t.bigint "verification_case_id", null: false + t.string "key", null: false + t.string "actor_type" + t.bigint "actor_id" + t.jsonb "data", default: {}, null: false + t.string "ip_address" + t.string "user_agent" + t.datetime "created_at", null: false + t.index ["actor_type", "actor_id"], name: "index_verification_case_events_on_actor" + t.index ["verification_case_id"], name: "index_verification_case_events_on_verification_case_id" + end + + create_table "verification_cases", force: :cascade do |t| + t.bigint "identity_id", null: false + t.bigint "opened_by_id" + t.bigint "verification_id" + t.string "status", null: false + t.string "document_class" + t.string "alternative_reason" + t.text "alternative_reason_details" + t.string "persona_inquiry_id" + t.text "persona_session_token" + t.jsonb "persona_signal_snapshot" + t.string "access_token" + t.datetime "access_token_expires_at" + t.datetime "access_token_used_at" + t.boolean "skip_persona", default: false, null: false + t.string "booking_uid" + t.datetime "call_starts_at" + t.jsonb "submitted_fields", default: {}, null: false + t.boolean "attested", default: false, null: false + t.boolean "biometric_consent", default: false, null: false + t.boolean "recording_consent_acknowledged", default: false, null: false + t.datetime "link_sent_at" + t.datetime "docs_submitted_at" + t.datetime "call_scheduled_at" + t.datetime "call_held_at" + t.datetime "approved_at" + t.datetime "denied_at" + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["access_token"], name: "index_verification_cases_on_access_token", unique: true, where: "(access_token IS NOT NULL)" + t.index ["deleted_at"], name: "index_verification_cases_on_deleted_at" + t.index ["identity_id"], name: "index_verification_cases_on_identity_id" + t.index ["opened_by_id"], name: "index_verification_cases_on_opened_by_id" + t.index ["persona_inquiry_id"], name: "index_verification_cases_on_persona_inquiry_id", unique: true, where: "((persona_inquiry_id IS NOT NULL) AND (deleted_at IS NULL))" + t.index ["status"], name: "index_verification_cases_on_status" + t.index ["verification_id"], name: "index_verification_cases_on_verification_id" + end + create_table "verifications", force: :cascade do |t| t.bigint "identity_id", null: false t.bigint "identity_document_id" @@ -639,6 +714,11 @@ t.string "persona_inquiry_id" t.text "persona_session_token" t.bigint "persona_record_id" + t.bigint "reviewer_id" + t.jsonb "checklist" + t.datetime "expires_at" + t.datetime "sampled_at" + t.bigint "sample_reviewer_id" t.index ["aadhaar_record_id"], name: "index_verifications_on_aadhaar_record_id" t.index ["deleted_at"], name: "index_verifications_on_deleted_at" t.index ["fatal"], name: "index_verifications_on_fatal" @@ -646,6 +726,7 @@ t.index ["identity_id"], name: "index_verifications_on_identity_id" t.index ["persona_inquiry_id"], name: "index_verifications_on_persona_inquiry_id", unique: true, where: "(persona_inquiry_id IS NOT NULL)" t.index ["persona_record_id"], name: "index_verifications_on_persona_record_id" + t.index ["reviewer_id"], name: "index_verifications_on_reviewer_id" t.index ["type"], name: "index_verifications_on_type" end @@ -696,6 +777,15 @@ add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", on_delete: :cascade add_foreign_key "program_collaborators", "identities" add_foreign_key "program_collaborators", "oauth_applications", column: "program_id" + add_foreign_key "verification_case_comments", "backend_users", column: "author_id" + add_foreign_key "verification_case_comments", "verification_cases" + add_foreign_key "verification_case_documents", "verification_cases" + add_foreign_key "verification_case_events", "verification_cases" + add_foreign_key "verification_cases", "backend_users", column: "opened_by_id" + add_foreign_key "verification_cases", "identities" + add_foreign_key "verification_cases", "verifications" + add_foreign_key "verifications", "backend_users", column: "reviewer_id" + add_foreign_key "verifications", "backend_users", column: "sample_reviewer_id" add_foreign_key "verifications", "identities" add_foreign_key "verifications", "identity_aadhaar_records", column: "aadhaar_record_id" add_foreign_key "verifications", "identity_documents" diff --git a/spec/factories/verification_cases.rb b/spec/factories/verification_cases.rb new file mode 100644 index 00000000..afb8788a --- /dev/null +++ b/spec/factories/verification_cases.rb @@ -0,0 +1,78 @@ +FactoryBot.define do + factory :verification_case do + association :identity + status { :requested } + + trait :link_sent do + status { :link_sent } + document_class { "government_id" } + access_token { SecureRandom.urlsafe_base64(32) } + access_token_expires_at { 7.days.from_now } + end + + trait :alternative do + document_class { "alternative" } + alternative_reason { "no_government_id" } + end + + trait :skip_persona do + skip_persona { true } + end + + trait :docs_submitted do + link_sent + status { :docs_submitted } + attested { true } + biometric_consent { true } + end + + trait :call_scheduled do + docs_submitted + status { :call_scheduled } + booking_uid { "bkng_#{SecureRandom.hex(6)}" } + call_starts_at { 2.days.from_now } + recording_consent_acknowledged { true } + end + + trait :call_held do + call_scheduled + status { :call_held } + end + end + + factory :verification_case_document, class: "VerificationCase::Document" do + association :verification_case + document_kind { "primary_doc" } + source { "direct_upload" } + + after(:build) do |doc| + doc.file.attach( + io: StringIO.new("fake document"), + filename: "document.pdf", + content_type: "application/pdf" + ) + end + + trait :recording do + document_kind { "call_recording" } + source { "call_recording" } + end + end + + factory :manual_verification_call, class: "Verification::ManualVerificationCall" do + association :identity + association :reviewer, factory: :backend_user + status { :pending } + checklist do + { + "doc_matches_live_face" => true, + "doc_matches_selfie" => true, + "name_dob_consistent" => true, + "signals_clean" => true, + "doc_unaltered" => true, + "confidence" => "high", + "notes" => "all clear on the call" + } + end + end +end diff --git a/spec/models/verification/manual_verification_call_spec.rb b/spec/models/verification/manual_verification_call_spec.rb new file mode 100644 index 00000000..6ede12a6 --- /dev/null +++ b/spec/models/verification/manual_verification_call_spec.rb @@ -0,0 +1,49 @@ +require "rails_helper" + +RSpec.describe Verification::ManualVerificationCall, type: :model do + it "requires a reviewer" do + verification = build(:manual_verification_call, reviewer: nil) + expect(verification).not_to be_valid + end + + it "requires a complete checklist to approve" do + verification = create(:manual_verification_call) + verification.checklist = { "confidence" => "high" } + expect { verification.approve! }.to raise_error(ActiveRecord::RecordInvalid, /missing answers/) + end + + it "approves with a full checklist" do + verification = create(:manual_verification_call) + verification.approve! + expect(verification.reload).to be_approved + end + + it "requires a confidence level" do + verification = create(:manual_verification_call) + verification.checklist = verification.checklist.except("confidence") + expect { verification.approve! }.to raise_error(ActiveRecord::RecordInvalid, /confidence/) + end + + it "records rejection with the shared machinery" do + verification = create(:manual_verification_call) + verification.mark_as_rejected!("no_show", nil) + expect(verification.reload).to be_rejected + expect(verification.fatal).to be(false) + end + + it "treats fraud as fatal" do + verification = create(:manual_verification_call) + verification.mark_as_rejected!("fraud", nil) + expect(verification.fatal).to be(true) + end + + describe "#expired?" do + it "is false with no expiry (tier A)" do + expect(build(:manual_verification_call, expires_at: nil).expired?).to be(false) + end + + it "is true past expiry (tier B backstop)" do + expect(build(:manual_verification_call, expires_at: 1.day.ago).expired?).to be(true) + end + end +end diff --git a/spec/models/verification_case_spec.rb b/spec/models/verification_case_spec.rb new file mode 100644 index 00000000..ab04d88b --- /dev/null +++ b/spec/models/verification_case_spec.rb @@ -0,0 +1,166 @@ +require "rails_helper" + +RSpec.describe VerificationCase, type: :model do + describe "state machine" do + it "walks the happy path" do + kase = create(:verification_case) + expect(kase).to be_requested + + kase.send_link! + expect(kase).to be_link_sent + expect(kase.link_sent_at).to be_present + + kase.update!(document_class: "government_id") + kase.submit_docs! + kase.schedule_call! + kase.hold_call! + kase.approve! + + expect(kase).to be_approved + expect(kase).to be_decided + end + + it "cannot decide before the call is held" do + kase = create(:verification_case, :docs_submitted) + expect { kase.approve! }.to raise_error(AASM::InvalidTransition) + end + + it "only allows deciding from call_held" do + kase = create(:verification_case, :call_scheduled) + expect { kase.deny! }.to raise_error(AASM::InvalidTransition) + end + + it "revokes the flag on decision and keeps documents" do + kase = create(:verification_case, :call_held) + doc = create(:verification_case_document, verification_case: kase) + Flipper.enable(described_class::FLIPPER_FLAG, kase.identity) + + kase.approve! + + expect(Flipper.enabled?(described_class::FLIPPER_FLAG, kase.identity)).to be(false) + expect(doc.reload.file).to be_attached + end + end + + describe "alternative-docs validation" do + it "requires a reason" do + kase = build(:verification_case, document_class: "alternative", alternative_reason: nil) + expect(kase).not_to be_valid + end + + it "requires details when reason is other" do + kase = build(:verification_case, document_class: "alternative", alternative_reason: "other", alternative_reason_details: nil) + expect(kase).not_to be_valid + kase.alternative_reason_details = "long story" + expect(kase).to be_valid + end + end + + describe "single-use access token" do + let(:kase) { create(:verification_case) } + + it "consumes exactly once" do + token = kase.generate_access_token! + expect(kase.consume_access_token!(token)).to be(true) + expect(kase.reload.access_token_used_at).to be_present + expect(kase.consume_access_token!(token)).to be(false) + end + + it "rejects wrong and expired tokens" do + token = kase.generate_access_token! + expect(kase.consume_access_token!("nope")).to be(false) + + kase.update!(access_token_expires_at: 1.minute.ago) + expect(kase.consume_access_token!(token)).to be(false) + end + end + + describe "#unschedule_call" do + it "returns a cancelled booking to docs_submitted so the user can rebook" do + kase = create(:verification_case, :call_scheduled) + kase.unschedule_call! + expect(kase).to be_docs_submitted + end + + it "is not available once the call was held" do + kase = create(:verification_case, :call_held) + expect { kase.unschedule_call! }.to raise_error(AASM::InvalidTransition) + end + end + + describe "#persona_capture_available?" do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("PERSONA_MANUAL_CAPTURE_TEMPLATE").and_return("itmpl_test123") + end + + it "is true with a template and no bypass" do + expect(build(:verification_case, document_class: "government_id")).to be_persona_capture_available + end + + it "is false when the case skips persona" do + kase = build(:verification_case, document_class: "government_id", skip_persona: true) + expect(kase).not_to be_persona_capture_available + expect(kase.generate_capture_inquiry!).to be_nil + end + end + + describe "comments" do + it "belong to a backend author and require a body" do + kase = create(:verification_case) + author = create(:backend_user) + comment = kase.comments.create!(author: author, body: "leaning approve, doc looks legit") + expect(kase.comments.chronological).to eq([ comment ]) + expect(kase.comments.build(author: author, body: "")).not_to be_valid + end + end + + describe "#capture_template_id" do + it "uses the shared template for either document class, from ENV fallback" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("PERSONA_MANUAL_CAPTURE_TEMPLATE").and_return("itmpl_test123") + + expect(build(:verification_case, document_class: "government_id").capture_template_id).to eq("itmpl_test123") + expect(build(:verification_case, document_class: "alternative", alternative_reason: "no_government_id").capture_template_id).to eq("itmpl_test123") + end + + it "is nil before a document class is chosen" do + expect(build(:verification_case).capture_template_id).to be_nil + end + end + + describe "#booking_url" do + it "appends case metadata to the configured link" do + kase = create(:verification_case, :docs_submitted) + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("CALCOM_MANUAL_VERIFICATION_BOOKING_URL").and_return("https://cal.example.com/team/verify") + + expect(kase.booking_url).to include("metadata%5BcasePublicId%5D=#{CGI.escape(kase.public_id)}") + end + end + + describe "document break-glass" do + it "records the activity against the case's identity" do + kase = create(:verification_case) + doc = create(:verification_case_document, verification_case: kase) + + record = BreakGlassRecord.create!( + backend_user: create(:backend_user), + break_glassable: doc, + reason: "reviewing before the call", + accessed_at: Time.current + ) + + expect(record.activities.last.recipient).to eq(kase.identity) + end + end + + describe "events" do + it "are append-only" do + kase = create(:verification_case) + event = kase.log_event!(:case_opened) + expect { event.update!(key: "tampered") }.to raise_error(ActiveRecord::ReadOnlyRecord) + expect { event.destroy! }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + end +end diff --git a/spec/policies/verification_case_policy_spec.rb b/spec/policies/verification_case_policy_spec.rb new file mode 100644 index 00000000..2670951f --- /dev/null +++ b/spec/policies/verification_case_policy_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe VerificationCasePolicy do + let(:verifier) { create(:backend_user, manual_document_verifier: true) } + let(:super_admin) { create(:backend_user, super_admin: true) } + let(:pleb) { create(:backend_user) } + + describe "case management" do + let(:kase) { create(:verification_case, :call_held) } + + it "allows manual document verifiers" do + expect(described_class.new(verifier, kase).create?).to be(true) + expect(described_class.new(verifier, kase).show?).to be(true) + expect(described_class.new(verifier, kase).decide?).to be(true) + end + + it "denies users without the role" do + expect(described_class.new(pleb, kase).show?).to be(false) + expect(described_class.new(pleb, kase).decide?).to be(false) + end + end + + describe "comments" do + let(:kase) { create(:verification_case, :call_held) } + + it "allows verifiers and denies others" do + expect(described_class.new(verifier, kase).comment?).to be(true) + expect(described_class.new(pleb, kase).comment?).to be(false) + end + end +end diff --git a/spec/requests/backend/verification_cases_spec.rb b/spec/requests/backend/verification_cases_spec.rb new file mode 100644 index 00000000..001cf65e --- /dev/null +++ b/spec/requests/backend/verification_cases_spec.rb @@ -0,0 +1,117 @@ +require "rails_helper" + +RSpec.describe "Backend verification cases", type: :request do + let(:verifier) { create(:backend_user, manual_document_verifier: true) } + + before do + allow_any_instance_of(Backend::ApplicationController).to receive(:current_identity).and_return(verifier.identity) + allow_any_instance_of(Backend::ApplicationController).to receive(:authenticate_user!).and_return(true) + allow_any_instance_of(Backend::ApplicationController).to receive(:require_2fa!).and_return(true) + end + + after { Flipper.disable(VerificationCase::FLIPPER_FLAG) } + + describe "POST /backend/verification_cases" do + it "opens a case, enables the flag, and emails the single-use link" do + target = create(:identity) + + expect { + post backend_verification_cases_path, params: { identity_id: target.public_id } + }.to have_enqueued_mail(VerificationCaseMailer, :invitation) + + kase = target.verification_cases.sole + expect(kase).to be_link_sent + expect(kase.skip_persona).to be(false) + expect(Flipper.enabled?(VerificationCase::FLIPPER_FLAG, target)).to be(true) + end + + it "opens a skip-persona case when asked" do + target = create(:identity) + post backend_verification_cases_path, params: { identity_id: target.public_id, skip_persona: "1" } + expect(target.verification_cases.sole.skip_persona).to be(true) + end + end + + describe "POST /backend/verification_cases/:id/comment" do + it "records a comment by the current reviewer" do + kase = create(:verification_case, :docs_submitted) + + post comment_backend_verification_case_path(kase), params: { body: "docs look consistent with the account" } + + comment = kase.comments.sole + expect(comment.author).to eq(verifier) + expect(comment.body).to include("consistent") + end + end + + describe "PATCH /backend/verification_cases/:id/decide" do + let(:full_checklist) do + { + doc_matches_live_face: "yes", + doc_matches_selfie: "yes", + name_dob_consistent: "yes", + signals_clean: "yes", + doc_unaltered: "yes" + } + end + + it "approves on the first reviewer's judgment, even with risk signals present" do + kase = create(:verification_case, :call_held, persona_inquiry_id: "inq_test123", + persona_signal_snapshot: { "network_signals" => { "country_code" => "RO" } }) + kase.identity.update_column(:created_at, 2.days.ago) + + patch decide_backend_verification_case_path(kase), + params: { decision: "approve", checklist: full_checklist, confidence: "high" } + + kase.reload + expect(kase).to be_approved + expect(kase.verification).to be_approved + expect(kase.verification.reviewer).to eq(verifier) + end + + it "records the selfie item as n/a when the case has no selfie at all" do + kase = create(:verification_case, :call_held) # persona-less direct upload, no selfie + + patch decide_backend_verification_case_path(kase), + params: { decision: "approve", checklist: full_checklist.except(:doc_matches_selfie), confidence: "high" } + + kase.reload + expect(kase).to be_approved + expect(kase.verification.checklist).to have_key("doc_matches_selfie") + expect(kase.verification.checklist_answer("doc_matches_selfie")).to be_nil + end + + it "lets the reviewer answer the selfie item on a skip-persona case with a live selfie" do + kase = create(:verification_case, :call_held, skip_persona: true) + create(:verification_case_document, verification_case: kase, document_kind: "selfie") + + patch decide_backend_verification_case_path(kase), + params: { decision: "approve", checklist: full_checklist, confidence: "high" } + + kase.reload + expect(kase).to be_approved + expect(kase.verification.checklist_answer("doc_matches_selfie")).to be(true) + end + + it "denies with a rejection reason" do + kase = create(:verification_case, :call_held, persona_inquiry_id: "inq_test456") + + patch decide_backend_verification_case_path(kase), + params: { decision: "deny", checklist: full_checklist, confidence: "high", rejection_reason: "no_show" } + + kase.reload + expect(kase).to be_denied + expect(kase.verification).to be_rejected + end + + it "rejects deciding before the call is held" do + kase = create(:verification_case, :call_scheduled) + + patch decide_backend_verification_case_path(kase), + params: { decision: "approve", checklist: full_checklist, confidence: "high" } + + expect(kase.reload).to be_call_scheduled + expect(kase.verification).to be_nil + end + end +end diff --git a/spec/requests/manual_verifications_spec.rb b/spec/requests/manual_verifications_spec.rb new file mode 100644 index 00000000..f9fbc3f4 --- /dev/null +++ b/spec/requests/manual_verifications_spec.rb @@ -0,0 +1,184 @@ +require "rails_helper" + +RSpec.describe "Manual verifications", type: :request do + let(:identity) { create(:identity) } + let(:session) do + identity.sessions.create!( + session_token: SecureRandom.hex(32), + expires_at: 1.week.from_now + ) + end + + before do + allow_any_instance_of(ApplicationController).to receive(:current_identity).and_return(identity) + allow_any_instance_of(ApplicationController).to receive(:current_session).and_return(session) + allow_any_instance_of(ApplicationController).to receive(:identity_signed_in?).and_return(true) + end + + after { Flipper.disable(VerificationCase::FLIPPER_FLAG) } + + describe "gating" do + it "bounces users without the flipper flag" do + get manual_verification_path + expect(response).to redirect_to(root_path) + end + + it "bounces flagged users with no open case" do + Flipper.enable(VerificationCase::FLIPPER_FLAG, identity) + get manual_verification_path + expect(response).to redirect_to(root_path) + end + + it "requires the single-use token on first visit" do + kase = create(:verification_case, identity: identity, status: :link_sent) + kase.generate_access_token! + Flipper.enable(VerificationCase::FLIPPER_FLAG, identity) + + get manual_verification_path + expect(response).to have_http_status(:forbidden) + end + + it "consumes a valid token then allows session access" do + kase = create(:verification_case, identity: identity, status: :link_sent) + token = kase.generate_access_token! + Flipper.enable(VerificationCase::FLIPPER_FLAG, identity) + + get manual_verification_path(token: token) + expect(response).to redirect_to(manual_verification_path) + expect(kase.reload.access_token_used_at).to be_present + + get manual_verification_path + expect(response).to have_http_status(:ok) + end + end + + describe "the flow" do + let(:kase) do + create(:verification_case, identity: identity, status: :link_sent, + access_token_used_at: Time.current) + end + + before do + kase + Flipper.enable(VerificationCase::FLIPPER_FLAG, identity) + end + + it "shows document class selection first" do + get manual_verification_path + expect(response.body).to include("Which describes you?") + end + + it "records government ID selection" do + post manual_verification_document_class_path, params: { document_class: "government_id" } + expect(kase.reload.document_class).to eq("government_id") + expect(kase.events.where(key: "document_class_selected")).to exist + end + + it "nudges the alternative path once before accepting" do + post manual_verification_document_class_path, params: { document_class: "alternative", alternative_reason: "no_government_id" } + expect(response.body).to include("One more thing") + expect(kase.reload.document_class).to be_nil + + post manual_verification_document_class_path, params: { document_class: "alternative", alternative_reason: "no_government_id", nudge_confirmed: "true" } + expect(kase.reload.document_class).to eq("alternative") + end + + it "requires attestation and biometric consent to submit documents" do + kase.update!(document_class: "government_id") + + post manual_verification_documents_path, params: { + primary_doc: fixture_file_upload_for("doc.pdf"), + legal_name: "Heidi Trashworth" + } + expect(kase.reload).to be_link_sent # not advanced + end + + it "accepts a government ID direct upload and advances the case" do + kase.update!(document_class: "government_id") + + post manual_verification_documents_path, params: { + primary_doc: fixture_file_upload_for("doc.pdf"), + attested: "1", biometric_consent: "1", + legal_name: "Heidi Trashworth", date_of_birth: "2008-04-01", + document_type: "passport", issuing_authority: "Romania" + } + + kase.reload + expect(kase).to be_docs_submitted + expect(kase.attested).to be(true) + expect(kase.biometric_consent).to be(true) + expect(kase.submitted_fields["legal_name"]).to eq("Heidi Trashworth") + expect(kase.documents.count).to eq(1) + end + + it "requires camera-only document AND selfie on a skip-persona case" do + kase.update!(document_class: "government_id", skip_persona: true) + base_params = { + attested: "1", biometric_consent: "1", + legal_name: "Heidi Trashworth", date_of_birth: "2008-04-01", + document_type: "passport", issuing_authority: "Romania" + } + + # pdf blocked — camera JPEG/PNG only + post manual_verification_documents_path, params: base_params.merge( + primary_doc: fixture_file_upload_for("doc.pdf"), + selfie: camera_capture_upload("selfie-capture.jpg") + ) + expect(kase.reload).to be_link_sent + + # selfie missing — blocked + post manual_verification_documents_path, params: base_params.merge( + primary_doc: camera_capture_upload("document-capture.jpg") + ) + expect(kase.reload).to be_link_sent + + # both camera captures — accepted, selfie stored as its own document + post manual_verification_documents_path, params: base_params.merge( + primary_doc: camera_capture_upload("document-capture.jpg"), + selfie: camera_capture_upload("selfie-capture.jpg") + ) + kase.reload + expect(kase).to be_docs_submitted + expect(kase.documents.where(document_kind: "selfie").count).to eq(1) + expect(kase.selfie_available?).to be(true) + end + + it "keeps skip-persona cases away from the persona capture flow" do + kase.update!(document_class: "government_id", skip_persona: true) + + get manual_verification_capture_path + expect(response).to redirect_to(manual_verification_path) + expect(kase.reload.persona_inquiry_id).to be_nil + end + + it "redirects the legacy verification pages to the open case" do + get verification_status_path + expect(response).to redirect_to(manual_verification_path) + + get portal_verify_document_path + expect(response).to redirect_to(manual_verification_path) + end + + it "gates the booking link behind the recording acknowledgment" do + kase.update!(document_class: "government_id", status: :docs_submitted) + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("CALCOM_MANUAL_VERIFICATION_BOOKING_URL").and_return("https://cal.example.com/verify") + + get manual_verification_path + expect(response.body).to include("verification calls are recorded") + expect(response.body).not_to include("https://cal.example.com/verify") + + post manual_verification_recording_ack_path + get manual_verification_path + expect(response.body).to include("https://cal.example.com/verify") + end + end + + def fixture_file_upload_for(name) + Rack::Test::UploadedFile.new(StringIO.new("fake pdf bytes"), "application/pdf", original_filename: name) + end + + def camera_capture_upload(name) + Rack::Test::UploadedFile.new(StringIO.new("fake jpeg bytes"), "image/jpeg", original_filename: name) + end +end diff --git a/spec/requests/webhooks/calcom_spec.rb b/spec/requests/webhooks/calcom_spec.rb new file mode 100644 index 00000000..1bfa57f2 --- /dev/null +++ b/spec/requests/webhooks/calcom_spec.rb @@ -0,0 +1,128 @@ +require "rails_helper" + +RSpec.describe "Cal.com webhooks", type: :request do + let(:secret) { "test-calcom-secret" } + let(:kase) { create(:verification_case, :docs_submitted) } + + let(:payload) do + { + triggerEvent: "BOOKING_CREATED", + payload: { + uid: "bkng_123", + startTime: 2.days.from_now.iso8601, + attendees: [ { email: kase.identity.primary_email } ], + metadata: { casePublicId: kase.public_id } + } + }.to_json + end + + def signature_for(body) + OpenSSL::HMAC.hexdigest("SHA256", secret, body) + end + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("CALCOM_WEBHOOK_SECRET").and_return(secret) + end + + it "rejects a missing signature" do + post "/webhooks/calcom", params: payload, headers: { "CONTENT_TYPE" => "application/json" } + expect(response).to have_http_status(:unauthorized) + end + + it "rejects a bad signature" do + post "/webhooks/calcom", params: payload, + headers: { "CONTENT_TYPE" => "application/json", "X-Cal-Signature-256" => "bogus" } + expect(response).to have_http_status(:unauthorized) + end + + it "enqueues booking processing for a valid signature" do + expect { + post "/webhooks/calcom", params: payload, + headers: { "CONTENT_TYPE" => "application/json", "X-Cal-Signature-256" => signature_for(payload) } + }.to have_enqueued_job(Calcom::ProcessBookingEventJob) + expect(response).to have_http_status(:ok) + end + + it "schedules the call when the job runs" do + Calcom::ProcessBookingEventJob.perform_now( + event: "BOOKING_CREATED", + case_id: kase.id, + booking_uid: "bkng_123", + starts_at: 2.days.from_now.iso8601 + ) + expect(kase.reload).to be_call_scheduled + expect(kase.booking_uid).to eq("bkng_123") + expect(kase.events.where(key: "call_booked")).to exist + end + + it "returns a cancelled booking to docs_submitted (cal.com owns the rebook email)" do + kase = create(:verification_case, :call_scheduled) + + expect { + Calcom::ProcessBookingEventJob.perform_now( + event: "BOOKING_CANCELLED", + case_id: kase.id, + booking_uid: kase.booking_uid, + starts_at: nil + ) + }.not_to have_enqueued_mail + + expect(kase.reload).to be_docs_submitted + expect(kase.booking_uid).to be_nil + expect(kase.call_starts_at).to be_nil + expect(kase.events.where(key: "call_cancelled")).to exist + end + + it "matches no-show events by stored booking uid (that payload has no metadata)" do + kase = create(:verification_case, :call_scheduled) + body = { + triggerEvent: "BOOKING_NO_SHOW_UPDATED", + payload: { + bookingUid: kase.booking_uid, + attendees: [ { email: "not-the-account-email@example.com", noShow: true } ] + } + }.to_json + + expect { + post "/webhooks/calcom", params: body, + headers: { "CONTENT_TYPE" => "application/json", "X-Cal-Signature-256" => signature_for(body) } + }.to have_enqueued_job(Calcom::ProcessBookingEventJob).with( + event: "BOOKING_NO_SHOW_UPDATED", case_id: kase.id, booking_uid: kase.booking_uid, starts_at: nil, no_show: true + ) + end + + it "reopens booking and logs the no-show when the host marks one" do + kase = create(:verification_case, :call_scheduled) + + Calcom::ProcessBookingEventJob.perform_now( + event: "BOOKING_NO_SHOW_UPDATED", case_id: kase.id, + booking_uid: kase.booking_uid, starts_at: nil, no_show: true + ) + + expect(kase.reload).to be_docs_submitted + expect(kase.booking_uid).to be_nil + expect(kase.events.where(key: "call_no_show")).to exist + end + + it "ignores a no-show being un-marked" do + kase = create(:verification_case, :call_scheduled) + + Calcom::ProcessBookingEventJob.perform_now( + event: "BOOKING_NO_SHOW_UPDATED", case_id: kase.id, + booking_uid: kase.booking_uid, starts_at: nil, no_show: false + ) + + expect(kase.reload).to be_call_scheduled + expect(kase.events.where(key: "call_no_show")).not_to exist + end + + it "lets the user book again after a cancellation" do + kase = create(:verification_case, :call_scheduled) + Calcom::ProcessBookingEventJob.perform_now(event: "BOOKING_CANCELLED", case_id: kase.id, booking_uid: kase.booking_uid, starts_at: nil) + Calcom::ProcessBookingEventJob.perform_now(event: "BOOKING_CREATED", case_id: kase.id, booking_uid: "bkng_rebooked", starts_at: 3.days.from_now.iso8601) + + expect(kase.reload).to be_call_scheduled + expect(kase.booking_uid).to eq("bkng_rebooked") + end +end diff --git a/spec/services/deletion_service_spec.rb b/spec/services/deletion_service_spec.rb index cc094cbe..c7c765f9 100644 --- a/spec/services/deletion_service_spec.rb +++ b/spec/services/deletion_service_spec.rb @@ -116,5 +116,15 @@ described_class.execute_deletion(identity, privacy_request_reference: "recASDASDASD", logger: ->(_) { }) }.to change { PublicActivity::Activity.where(key: "identity.deletion_request").count }.by(1) end + + it "purges verification case document files" do + kase = create(:verification_case, identity: identity) + doc = create(:verification_case_document, verification_case: kase) + blob_id = doc.file.blob.id + + described_class.execute_deletion(identity, privacy_request_reference: "recASDASDASD", logger: ->(_) { }) + + expect(ActiveStorage::Attachment.where(blob_id: blob_id)).to be_empty + end end end |