Canonical sources: Portal philosophy, posture, and language live in
/docs/planning/champion-portal/source/README.md.Compliance documents:
/docs/compliance/LEGAL_REVIEW.md,/docs/compliance/IT_SECURITY_REVIEW.mdLegal drafts:
/docs/compliance/drafts/(ToS, Privacy Policy, Community Guidelines, Cookie Policy)
Status: ✅ Complete (16.1 ✅, 16.2 ✅, 16.3 ✅, 16.4 ✅, 16.5 ✅, 16.6 ✅) Prerequisites: Phase 15 Complete ✅ Type: Compliance & privacy implementation — models, controllers, views, mailers, security configuration
A comprehensive Legal Review and IT Security Review have been completed in preparation for the Alumni Network’s public beta launch. Those reviews identified implementation gaps that, if addressed proactively, will present the legal and IT security review teams with a locked-up case requiring only language approval — not engineering recommendations.
This phase implements every actionable recommendation from:
/docs/compliance/LEGAL_REVIEW.md/docs/compliance/IT_SECURITY_REVIEW.mdDraft legal documents (Terms of Service, Privacy Policy, Community Guidelines, Cookie Policy) have been prepared for legal counsel at /docs/compliance/drafts/. This phase builds the in-app infrastructure to display, version, and enforce those documents.
Every sub-phase maps back to specific findings in the compliance reviews.
| Sub-Phase | Legal Review Gap # | IT Security Review § | Priority |
|---|---|---|---|
| 16.1 | #1 (ToS), #2 (PP), #3 (Consent), #4 (CG) | — | 🔴 Critical |
| 16.2 | #6 (FERPA considerations) | — | 🟡 High |
| 16.3 | #5 (Account Deletion), #14 (Data Export) | — | 🟡 High |
| 16.4 | #7 (CAN-SPAM) | — | 🟡 High |
| 16.5 | #16 (CSP) | §17.1 (CSP), §17.4 (Headers) | 🟡 High |
| 16.6 | #11 (Age Attestation) | — | 🟢 Medium |
| Sub-Phase | Name | Scope | Status |
|---|---|---|---|
| 16.1 | Legal Policy Pages & Consent Flow | Static policy pages + registration consent checkbox + re-consent | ✅ Complete |
| 16.2 | Education Privacy Controls | New privacy enum, 6 views, 3 search paths, 4 services | ✅ Complete |
| 16.3 | Account Deletion & Data Export | True deletion workflow + “Download My Data” | ✅ Complete |
| 16.4 | CAN-SPAM Email Compliance | Unsubscribe links + physical address in all emails | ✅ Complete |
| 16.5 | Security Headers | CSP, Permissions-Policy, Referrer-Policy | ✅ Complete |
| 16.6 | Age Attestation | Age confirmation checkbox at registration | ✅ Complete |
Serve the four legal documents as in-app pages, track policy versions, and require ToS + Privacy Policy acceptance at registration. Provide re-consent mechanism when policies are updated.
New table: cp_policy_acceptances
| Column | Type | Notes |
|---|---|---|
id |
bigint | PK |
champion_id |
bigint | FK → cp_champions |
policy_type |
string | terms_of_service, privacy_policy |
policy_version |
string | e.g., "2025-06-01" |
accepted_at |
datetime | Timestamp of acceptance |
ip_address |
inet | IP at time of acceptance |
created_at |
datetime |
[champion_id, policy_type][policy_type, policy_version]New columns on cp_champions
| Column | Type | Notes |
|---|---|---|
terms_accepted_at |
datetime | When ToS was last accepted |
terms_version |
string | Version string of accepted ToS |
privacy_accepted_at |
datetime | When PP was last accepted |
privacy_version |
string | Version string of accepted PP |
# Policy pages (public, no auth required)
namespace :cp do
get "terms", to: "policies#terms", as: :terms
get "privacy", to: "policies#privacy", as: :privacy
get "community-guidelines", to: "policies#community_guidelines", as: :community_guidelines
get "cookies", to: "policies#cookies", as: :cookies
end
The four policy pages will be populated with the reviewed draft documents located at /docs/compliance/drafts/. These drafts are the starting content for the in-app views and may be revised after legal approval without a code change (see versioning approach below).
| Route | Source Draft |
|---|---|
/terms |
docs/compliance/drafts/TERMS_OF_SERVICE.md |
/privacy |
docs/compliance/drafts/PRIVACY_POLICY.md |
/community-guidelines |
docs/compliance/drafts/COMMUNITY_GUIDELINES.md |
/cookies |
docs/compliance/drafts/COOKIE_POLICY.md |
Cross-document links to implement:
The draft documents reference each other by URL. These links must resolve to in-app routes:
| From | Link text | Target route |
|---|---|---|
| Privacy Policy | “Cookie Policy” | /cookies |
| Terms of Service | “Privacy Policy” | /privacy |
| Terms of Service | “Community Guidelines” | /community-guidelines |
| Registration form | “Terms of Service” | /terms |
| Registration form | “Privacy Policy” | /privacy |
| Email footers | “unsubscribe” | /settings?section=notifications |
| Portal footer (all pages) | All four policies | respective routes |
Versioning approach: Policy version strings (e.g., "2025-06-01") are defined in a config constant (Cp::PolicyVersion). Updating the constant triggers the re-consent flow for existing users without requiring a code change to the document content.
Cp::PoliciesControllerterms — Render Terms of Service (Markdown → HTML or static ERB)privacy — Render Privacy Policycommunity_guidelines — Render Community Guidelinescookies — Render Cookie Policycp/registrations/new.html.erb)Add before the submit button:
<div class="mt-4">
<label class="flex items-start gap-2">
<%= f.check_box :terms_accepted, required: true, class: "mt-1" %>
<span class="text-sm text-gray-600">
I agree to the
<%= link_to "Terms of Service", cp_terms_path, target: "_blank", class: "text-belmontblue underline" %>
and
<%= link_to "Privacy Policy", cp_privacy_path, target: "_blank", class: "text-belmontblue underline" %>.
</span>
</label>
</div>
Cp::RegistrationsController)terms_accepted presence (virtual attribute on model)terms_accepted_at, terms_version, privacy_accepted_at, privacy_versionCp::PolicyAcceptance records for bothCp::PolicyVersion::CURRENT)before_action :check_policy_consent to Cp::BaseControllerchampion.terms_version != Cp::PolicyVersion::CURRENT_TERMS, redirect to a consent update pageCp::PolicyAcceptance entryAdd links to Terms, Privacy, Community Guidelines, and Cookie Policy in the champion portal footer (app/views/layouts/cp/_footer.html.erb or equivalent).
Status: Complete
What Was Implemented:
Cp::PolicyVersion model — central config with CURRENT_TERMS / CURRENT_PRIVACY version constants; consent_current? and stale_policies class methodsCp::PolicyAcceptance model — immutable audit record with indexes on [champion_id, policy_type] and [policy_type, policy_version]cp_policy_acceptances table created via migration 20260305120000cp_champions: terms_accepted_at, terms_version, privacy_accepted_at, privacy_versionCp::PoliciesController — four public policy pages, champion layout, no auth requiredCp::ConsentController — re-consent show/update, skips check_policy_consent guard to avoid redirect loopcheck_policy_consent before-action added to Cp::BaseControllerterms_accepted attribute; validated on create; OAuth/admin registrations exempt via skip_consent_validation?record_policy_consent! model method handles both registration and re-consent — creates Cp::PolicyAcceptance records for both policies atomicallypolicy_consent_updated added to Cp::ActivityEvent::EVENT_TYPESdocs/compliance/drafts/ with internal cross-links resolvedNew Files:
app/models/cp/policy_version.rbapp/models/cp/policy_acceptance.rbapp/controllers/cp/policies_controller.rbapp/controllers/cp/consent_controller.rbapp/views/cp/policies/ (4 views)app/views/cp/consent/ (show + update form)db/migrate/20260305120000_create_cp_policy_acceptances_and_add_consent_to_champions.rbtest/models/cp/policy_version_test.rb (8 tests)test/models/cp/policy_acceptance_test.rb (9 tests)test/controllers/cp/policies_controller_test.rb (8 tests)test/controllers/cp/consent_controller_test.rb (12 tests)Modified Files:
app/models/cp/champion.rb — consent columns, record_policy_consent!, stale_policies, policy_consent_current?, skip_consent_validation?app/controllers/cp/base_controller.rb — check_policy_consent before-actionapp/controllers/cp/registrations_controller.rb — consent recording at registrationapp/views/cp/registrations/new.html.erb — consent checkboxapp/views/layouts/champions/_footer.html.erb — policy page linksconfig/routes.rb — policy and consent routesdb/schema.rbTests: 37 new tests + 16 consent assertions in champion model test. Full suite: 4081 runs, 0 failures.
Allow alumni to control visibility of their education records. Options: show all (default), hide graduation year only, or hide all education details (display only “Belmont University graduate”). Education privacy must ripple into directory search/filter results so that hidden data cannot be inferred from search results.
New column on cp_champions
| Column | Type | Default | Notes |
|---|---|---|---|
education_privacy |
integer (enum) | 0 (show_all) |
Privacy level for education data |
Enum values:
| Value | Name | Behavior |
|---|---|---|
| 0 | education_show_all |
Full education display (degree, major, college, year) |
| 1 | education_hide_year |
Show degree/major/college, hide graduation year |
| 2 | education_hidden |
Show only “Belmont University graduate” — no degree, major, college, or year |
Cp::Champion)enum :education_privacy, {
education_show_all: 0,
education_hide_year: 1,
education_hidden: 2
}, prefix: true, default: :education_show_all
Add helper methods:
def show_education_year?
education_privacy_education_show_all?
end
def show_education_details?
!education_privacy_education_hidden?
end
All education display locations must respect the privacy setting:
| View | Current Behavior | Change |
|---|---|---|
cp/directory/_champion_card.html.erb |
Shows degree + year | Conditionally hide based on champion.education_privacy |
cp/directory/show.html.erb |
Shows full education section | Conditionally hide year and/or all details |
cp/profile/show.html.erb |
Shows own education | Always show full education to the champion themselves |
cp/career_connect/_champion_card.html.erb |
Shows degree info | Conditionally hide based on privacy |
cp/almost_alumni/_mentor_card.html.erb |
Shows degree info | Conditionally hide based on privacy |
cp/almost_alumni/show.html.erb |
Shows mentor education | Conditionally hide based on privacy |
Display logic (for other-viewer):
<% if champion.education_privacy_education_show_all? %>
<!-- Full education: degree, major, college, year -->
<% elsif champion.education_privacy_education_hide_year? %>
<!-- Show degree, major, college — no year -->
<% else %>
<!-- "Belmont University graduate" only -->
<% end %>
Self-view exception: On cp/profile/show.html.erb, always show full education with a privacy indicator badge (e.g., “🔒 Hidden from others”).
Critical user requirement: “This would need to ripple down into any searches/filters that might reference those areas so that the alum does not show up in a search result that would imply their year or education information.”
| Path | File | Change |
|---|---|---|
| Graduation year filter | Cp::DirectoryController#apply_degree_filters |
Exclude education_hidden and education_hide_year champions from grad year filter results |
| College filter | Cp::DirectoryController#apply_degree_filters |
Exclude education_hidden champions from college filter results |
| Degree/major filter | Cp::DirectoryController#apply_degree_filters |
Exclude education_hidden champions from degree/major filter results |
Implementation approach:
# In apply_degree_filters, when grad_year filter is active:
scope = scope.where.not(education_privacy: :education_hide_year)
.where.not(education_privacy: :education_hidden)
# When college/degree/major filter is active:
scope = scope.where.not(education_privacy: :education_hidden)
This ensures a champion with hidden education cannot be discovered through education-based filters.
| Service | Change |
|---|---|
AlumniLikeMeService |
Exclude education factors from scoring when champion has education_hidden |
CareerConnectService |
Respect privacy when matching by career cluster / college |
CommunityMatchingService |
Exclude college-based suggestions when education_hidden |
CommunityDetectionService |
Skip college/major community detection when education_hidden |
Add education privacy control to cp/settings page (privacy section):
<label>Education Visibility</label>
<select name="champion[education_privacy]">
<option value="education_show_all">Show all education details</option>
<option value="education_hide_year">Hide graduation year</option>
<option value="education_hidden">Show only "Belmont University graduate"</option>
</select>
Update Cp::SettingsController to permit education_privacy parameter.
Record ActivityRecorder.record(champion, :privacy_updated) when education privacy changes.
education_show_allhide_year and hidden championshidden championsStatus: Complete
What Was Implemented:
education_privacy enum on Cp::Champion (education_show_all, education_hide_year, education_hidden) with prefix: trueshow_education_year? and show_education_details? for view conditionalsdirectory/_champion_card.html.erb, directory/show.html.erb, profile/show.html.erb, careers/_career_connect_cards.html.erbbuild_champion_query uses .active scope; apply_degree_filters excludes education_hidden from college/degree filters, excludes education_hide_year from grad year filtersAlumniLikeMeService.score_candidate skips college/major scoring for education_hidden, skips grad year scoring for education_hide_yearCareerConnectService.career_open_base_query excludes deleted and education-hidden championseducation_privacy_updated recorded on change via update_privacy_settingsSpec Deviations:
almost_alumni views removed from scope — no Almost Alumni feature currently deployedCommunityMatchingService and CommunityDetectionService unchanged — internal services that don’t expose education data to other usersModified Files:
app/models/cp/champion.rb — education_privacy enum + helper methodsapp/models/cp/activity_event.rb — new event typeapp/controllers/cp/directory_controller.rb — .active scope + filter exclusionsapp/controllers/cp/settings_controller.rb — education_privacy handling in update_privacy_settingsapp/services/cp/alumni_like_me_service.rb — privacy guards in score_candidateapp/services/cp/career_connect_service.rb — privacy guards in career_open_base_queryapp/views/cp/directory/_champion_card.html.erb, show.html.erbapp/views/cp/profile/show.html.erbapp/views/cp/careers/_career_connect_cards.html.erbapp/views/cp/settings/_section_privacy.html.erb — education visibility radio buttonsTests: 8 new champion model tests for education privacy. Full suite: 4115 runs, 0 failures.
Replace the current stub request_account_deletion (which only sets privacy fields to hidden) with a true account deletion workflow. Additionally, provide a “Download My Data” export for champions to retrieve their personal data.
The existing request_account_deletion in Cp::SettingsController (lines 55-66) does NOT delete anything — it only sets email_privacy, phone_privacy, and address_privacy to hidden. The flash message misleadingly says “Account deletion request received.”
Step 1: Confirmation page
Step 2: Deletion execution (Cp::AccountDeletionService)
New service that handles the full deletion:
class Cp::AccountDeletionService
def initialize(champion)
@champion = champion
end
def execute!
ActiveRecord::Base.transaction do
anonymize_discussions
delete_messages
remove_community_memberships
remove_connections
purge_uploaded_files
remove_activity_events
remove_policy_acceptances
remove_notification_preferences
remove_support_threads
clear_pii_fields
deactivate_account
end
end
private
def anonymize_discussions
# Set author to nil or a sentinel, update display to "Former Member"
@champion.discussion_posts.update_all(
author_name_cache: "Former Member",
champion_id: nil # or keep FK but mark deleted
)
end
def clear_pii_fields
# Overwrite all personally identifiable fields
@champion.update!(
first_name: "Deleted",
last_name: "User",
email: "deleted+#{@champion.id}@deleted.belmontalum.com",
phone: nil,
address_line_1: nil, address_line_2: nil,
city: nil, state: nil, zip_code: nil,
bio: nil, headline: nil,
linkedin_url: nil,
employer: nil, job_title: nil,
deleted_at: Time.current,
# ... all other PII fields
)
end
def deactivate_account
# Prevent login
@champion.update!(
account_status: :deleted,
deleted_at: Time.current
)
end
end
Step 3: Post-deletion
New columns on cp_champions
| Column | Type | Notes |
|---|---|---|
deleted_at |
datetime | When account was deleted (null = active) |
deletion_reason |
string | Optional reason provided by champion |
deletion_confirmed_at |
datetime | When deletion was confirmed |
Scope filter: Add scope :active, -> { where(deleted_at: nil) } to Cp::Champion and apply to directory/search queries.
Add a “Download My Data” button in Settings → Account section.
Controller: Cp::SettingsController#export_data
Service: Cp::DataExportService
Generates a JSON file containing all of the champion’s personal data:
Implementation:
class Cp::DataExportService
def initialize(champion)
@champion = champion
end
def generate
{
exported_at: Time.current.iso8601,
profile: profile_data,
communities: community_data,
discussions: discussion_data,
messages: sent_message_data,
connections: connection_data,
activity_events: activity_data,
privacy_settings: privacy_data,
policy_acceptances: acceptance_data
}.to_json
end
end
send_data as a .json file downloadActivityRecorder.record(champion, :data_exported)Status: Complete
What Was Implemented:
Cp::AccountDeletionService — Full soft-delete workflow within transaction:
author_id: nil){}deleted_at, deletion_reason, deletion_confirmed_at timestampsCp::DataExportService — Generates JSON export with 8 sections: profile, education, communities, discussions, messages, connections, activity_events, privacy_settingscp/settings/confirm_account_deletion.html.erb) requiring user to type “CONFIRM”GET /settings/account_deletion/confirm, DELETE /settings/account_deletion, POST /settings/export_dataactive and deleted scopes on Cp::ChampionCp::ChampionMailer.account_deletion_confirmation)New Database Columns:
cp_champions.deleted_at (datetime, indexed)cp_champions.deletion_reason (string)cp_champions.deletion_confirmed_at (datetime)Spec Deviations:
author_name_cache field doesn’t exist — used author_id: nil for anonymizationdependent: :destroy associations couldn’t use that option due to NOT NULL constraints — manual deletion in serviceNew Files:
app/services/cp/account_deletion_service.rbapp/services/cp/data_export_service.rbapp/views/cp/settings/confirm_account_deletion.html.erbapp/views/cp/champion_mailer/account_deletion_confirmation.html.erbapp/views/cp/champion_mailer/account_deletion_confirmation.text.erbtest/services/cp/account_deletion_service_test.rb (6 tests)test/services/cp/data_export_service_test.rb (4 tests)Tests: 10 new service tests + 7 new settings controller tests. Full suite: 4115 runs, 0 failures.
Ensure all non-transactional emails comply with CAN-SPAM Act requirements: clear sender identification, physical postal address, and a functioning unsubscribe mechanism.
shared/email/_branded_footer.html.erb partial includes the physical address (1900 Belmont Blvd) and a “Manage notification preferences” link ✅List-Unsubscribe header in outbound emails ❌shared/email/_branded_footer.html.erb)Add explicit unsubscribe language:
<p style="...">
<a href="<%= cp_settings_url(section: 'notifications') %>">
Manage preferences or unsubscribe
</a>
</p>
.text.erb mailer templates)Update the footer block in every text email template to include the full physical address:
---
Belmont Alumni Engagement
1900 Belmont Boulevard, Nashville, TN 37212
Manage preferences or unsubscribe: <%= cp_settings_url(section: 'notifications') %>
Files to update:
cp/notification_mailer/daily_digest.text.erbcp/notification_mailer/weekly_digest.text.erbcp/notification_mailer/immediate_notification.text.erbcp/admin_notification_mailer/weekly_digest.text.erbAdd List-Unsubscribe and List-Unsubscribe-Post headers to all non-transactional mailers:
# In Cp::NotificationMailer (or a shared concern)
def add_unsubscribe_headers(champion)
headers["List-Unsubscribe"] = "<#{cp_settings_url(section: 'notifications')}>"
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
end
This enables one-click unsubscribe in email clients (Gmail, Apple Mail).
The following emails are transactional and do NOT require unsubscribe links (but should still include the physical address):
Status: Complete
What Was Implemented:
Cp::CanSpamHeaders concern with after_action :add_list_unsubscribe_headers — adds List-Unsubscribe and List-Unsubscribe-Post headers pointing to notification settingsNotificationMailer, CommunityMailer, ContentSubmissionMailer, ModerationMailer, AdminNotificationMailer, SupportThreadMailer, FeedbackMailershared/email/_text_footer.text.erb) with full physical address (1900 Belmont Blvd, Nashville, TN 37212), unsubscribe link, and configurable reason textreason: Explains why user received emailmanage_url: Notification settings URL (auto-generated if not provided)transactional: Boolean — skips unsubscribe link for transactional emailstransactional: true to include address but skip unsubscribe linkNew Files:
app/mailers/concerns/cp/can_spam_headers.rbapp/views/shared/email/_text_footer.text.erbtest/mailers/cp/can_spam_headers_test.rb (2 tests)Modified Files:
app/views/shared/email/_branded_footer.html.erb — added “or unsubscribe” linkTests: 2 new CAN-SPAM header tests + 3 new ChampionMailer tests. Full suite: 4115 runs, 0 failures.
Enable Content Security Policy, Permissions-Policy, and Referrer-Policy headers to harden the application against XSS, clickjacking, and information leakage.
config/initializers/content_security_policy.rb — entirely commented outconfig/initializers/permissions_policy.rb — entirely commented outX-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniffReferrer-Policy or Strict-Transport-Security configured explicitlyconfig/initializers/content_security_policy.rb)Uncomment and configure:
Rails.application.configure do
config.content_security_policy do |policy|
policy.default_src :self
policy.font_src :self, "https://fonts.gstatic.com"
policy.img_src :self, "https:", :data # Allow HTTPS images + data URIs for inline images
policy.object_src :none
policy.script_src :self
policy.style_src :self, :unsafe_inline # Tailwind uses inline styles
policy.connect_src :self
policy.frame_src :none
policy.base_uri :self
policy.form_action :self, "https://accounts.google.com" # Google OAuth
end
# Generate nonce for inline scripts
config.content_security_policy_nonce_generator = ->(request) {
request.session.id.to_s
}
config.content_security_policy_nonce_directives = %w[script-src]
# Report-Only mode first (non-breaking)
config.content_security_policy_report_only = true
end
Deployment strategy:
report_only mode firstconfig/initializers/permissions_policy.rb)Uncomment and configure:
Rails.application.configure do
config.permissions_policy do |policy|
policy.camera :none
policy.microphone :none
policy.geolocation :none
policy.payment :none
policy.usb :none
policy.fullscreen :self
policy.gyroscope :none
policy.magnetometer :none
policy.accelerometer :none
end
end
Add to config/application.rb or via middleware:
config.action_dispatch.default_headers.merge!(
"Referrer-Policy" => "strict-origin-when-cross-origin"
)
config.action_dispatch.default_headers.merge!(
"Strict-Transport-Security" => "max-age=31536000; includeSubDomains"
)
(Only in production environment config to avoid HTTPS issues in development.)
strict-origin-when-cross-originStatus: Complete
What Was Implemented:
default-src 'self' — secure baselinefont_src and style_srcunsafe_inline for Tailwind CSS stylesform_actionimg_src for inline imagesapply_permissions_policy before_action:
:none:selfstrict-origin-when-cross-originconfig.force_ssl = true in productionModified Files:
config/initializers/content_security_policy.rb — enabled and configured CSPconfig/initializers/permissions_policy.rb — enabled and configured feature restrictionsconfig/environments/production.rb — added Referrer-Policy headerapp/controllers/application_controller.rb — added apply_permissions_policy before_actionNew Files:
test/integration/security_headers_test.rb — 11 tests covering Permissions-Policy, X-Frame-Options, X-Content-Type-Options, CSP report-only, Google Fonts whitelist, Google OAuth whitelistTests: 11 new integration tests. Full suite: 4126 runs, 0 failures.
Deployment Note: CSP is in report-only mode. Monitor logs for violations, then switch to enforcement by setting config.content_security_policy_report_only = false.
Add an age confirmation checkbox at registration establishing a minimum age of 18. This is consistent with the intended audience (university alumni and current college students, virtually all of whom are adults) and eliminates COPPA/child privacy compliance concerns entirely.
Add after the consent checkbox (from 16.1):
<div class="mt-3">
<label class="flex items-start gap-2">
<%= f.check_box :age_confirmed, required: true, class: "mt-1" %>
<span class="text-sm text-gray-600">
I confirm that I am at least 18 years of age.
</span>
</label>
</div>
Add virtual attribute to Cp::Champion:
attr_accessor :age_confirmed
validates :age_confirmed, acceptance: true, on: :create
New column on cp_champions
| Column | Type | Notes |
|---|---|---|
age_confirmed_at |
datetime | When age was attested |
Set age_confirmed_at during registration (same as terms_accepted_at).
age_confirmed_at set on successful registrationStatus: Complete
What Was Implemented:
attr_accessor :age_confirmed on Cp::Champion with validates :age_confirmed, acceptance: { accept: ['1', true] }, on: :create, unless: :skip_consent_validation?age_confirmed_at column on cp_champions (datetime)record_initial_policy_consent in Cp::RegistrationsController sets age_confirmed_at: Time.current alongside existing consent fieldsskip_consent_validation? methodDatabase Column:
cp_champions.age_confirmed_at (datetime) — added in combined Phase 16 migrationModified Files:
app/models/cp/champion.rb — age_confirmed attribute + validationapp/controllers/cp/registrations_controller.rb — age_confirmed_at recordingapp/views/cp/registrations/new.html.erb — age confirmation checkboxdb/migrate/20260306120000_add_education_privacy_deletion_and_age_to_cp_champions.rb — combined migrationTests: Champion model tests for age_confirmed validation (reject nil/”0”, accept “1”/true). Full suite: 4115 runs, 0 failures.
Sub-phases should be implemented in order because:
All migrations are additive (new columns, new tables). No destructive changes. Safe for zero-downtime deployment.
show_all means existing behavior unchanged| Sub-Phase | Effort | Files Changed | New Files |
|---|---|---|---|
| 16.1 | Medium | ~8 | ~6 (migration, model, controller, 4 views) |
| 16.2 | Medium-Large | ~12 | ~2 (migration, helpers) |
| 16.3 | Large | ~6 | ~4 (migration, 2 services, confirmation view) |
| 16.4 | Small | ~6 | ~1 (mailer concern) |
| 16.5 | Small | ~3 | 0 (config changes only) |
| 16.6 | Small | ~3 | ~1 (migration) |
| Total | ~2-3 weeks | ~38 | ~14 |