From 88a5db9f3cba35fd1029d4c38fffca2d9f0ed9f5 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Tue, 28 Jul 2026 17:17:41 -0400 Subject: [PATCH 1/2] Add private contest invite links with per-instance access modes. Private collections stay hidden from the dashboard; each contest instance gets an unguessable /c/:token URL and can require either anyone with the link or an invite email list, with hardened entry authorization. Co-authored-by: Cursor --- app/controllers/application_controller.rb | 1 + .../concerns/contest_invite_session.rb | 31 +++++ .../contest_instances_controller.rb | 10 +- .../contest_invitations_controller.rb | 64 +++++++++ app/controllers/contest_invites_controller.rb | 35 +++++ .../controllers/access_mode_controller.js | 18 +++ .../controllers/clipboard_controller.js | 30 +++++ app/javascript/controllers/index.js | 6 + app/models/contest_instance.rb | 52 ++++++++ app/models/contest_invitation.rb | 42 ++++++ app/models/current.rb | 6 + app/policies/contest_instance_policy.rb | 8 ++ app/policies/entry_policy.rb | 16 ++- .../_contest_instance.html.erb | 8 ++ .../_private_access.html.erb | 121 ++++++++++++++++++ config/routes.rb | 5 + ..._add_access_fields_to_contest_instances.rb | 25 ++++ ...260728162720_create_contest_invitations.rb | 16 +++ db/schema.rb | 18 ++- ..._instances_regenerate_access_token_spec.rb | 34 +++++ .../contest_invitations_controller_spec.rb | 48 +++++++ .../contest_invites_controller_spec.rb | 80 ++++++++++++ spec/factories/containers.rb | 8 ++ spec/factories/contest_instances.rb | 11 ++ spec/factories/contest_invitations.rb | 9 ++ spec/models/contest_instance_access_spec.rb | 96 ++++++++++++++ spec/models/contest_invitation_spec.rb | 27 ++++ spec/policies/entry_policy_create_spec.rb | 93 ++++++++++++++ 28 files changed, 915 insertions(+), 3 deletions(-) create mode 100644 app/controllers/concerns/contest_invite_session.rb create mode 100644 app/controllers/contest_invitations_controller.rb create mode 100644 app/controllers/contest_invites_controller.rb create mode 100644 app/javascript/controllers/access_mode_controller.js create mode 100644 app/javascript/controllers/clipboard_controller.js create mode 100644 app/models/contest_invitation.rb create mode 100644 app/models/current.rb create mode 100644 app/views/contest_instances/_private_access.html.erb create mode 100644 db/migrate/20260728162719_add_access_fields_to_contest_instances.rb create mode 100644 db/migrate/20260728162720_create_contest_invitations.rb create mode 100644 spec/controllers/contest_instances_regenerate_access_token_spec.rb create mode 100644 spec/controllers/contest_invitations_controller_spec.rb create mode 100644 spec/controllers/contest_invites_controller_spec.rb create mode 100644 spec/factories/contest_invitations.rb create mode 100644 spec/models/contest_instance_access_spec.rb create mode 100644 spec/models/contest_invitation_spec.rb create mode 100644 spec/policies/entry_policy_create_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 582b7917..ba2f2fbc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include Pundit::Authorization include ApplicationHelper include Pagy::Method + include ContestInviteSession before_action :authenticate_user! before_action :set_sentry_context diff --git a/app/controllers/concerns/contest_invite_session.rb b/app/controllers/concerns/contest_invite_session.rb new file mode 100644 index 00000000..0c42153b --- /dev/null +++ b/app/controllers/concerns/contest_invite_session.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ContestInviteSession + extend ActiveSupport::Concern + + REDEEMED_SESSION_KEY = :redeemed_contest_instances + + included do + before_action :load_redeemed_contest_instances_into_current + end + + private + + def load_redeemed_contest_instances_into_current + Current.redeemed_contest_instances = session[REDEEMED_SESSION_KEY] || {} + end + + def redeem_contest_instance!(contest_instance) + session[REDEEMED_SESSION_KEY] ||= {} + session[REDEEMED_SESSION_KEY][contest_instance.id.to_s] = contest_instance.access_token + Current.redeemed_contest_instances = session[REDEEMED_SESSION_KEY] + end + + def redeemed_token_for(contest_instance) + Current.redeemed_contest_instances&.dig(contest_instance.id.to_s) + end + + def contest_instance_redeemed?(contest_instance) + redeemed_token_for(contest_instance) == contest_instance.access_token + end +end diff --git a/app/controllers/contest_instances_controller.rb b/app/controllers/contest_instances_controller.rb index 19e90fa2..ccd4019d 100644 --- a/app/controllers/contest_instances_controller.rb +++ b/app/controllers/contest_instances_controller.rb @@ -1,7 +1,7 @@ class ContestInstancesController < ApplicationController before_action :set_container before_action :set_contest_description - before_action :set_contest_instance, only: %i[show edit update destroy send_round_results deactivate] + before_action :set_contest_instance, only: %i[show edit update destroy send_round_results deactivate regenerate_access_token] before_action :authorize_container_access # GET /contest_instances @@ -228,6 +228,13 @@ def deactivate end end + def regenerate_access_token + authorize @contest_instance, :regenerate_access_token? + @contest_instance.regenerate_access_token + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + notice: 'Invite link regenerated. Previous links no longer work.' + end + private def authorize_container_access @@ -261,6 +268,7 @@ def contest_instance_params :recletter_required, :transcript_required, :require_internal_comments, :require_external_comments, :min_internal_comment_words, :min_external_comment_words, + :access_mode, category_ids: [], class_level_ids: [] ) end diff --git a/app/controllers/contest_invitations_controller.rb b/app/controllers/contest_invitations_controller.rb new file mode 100644 index 00000000..2531bd15 --- /dev/null +++ b/app/controllers/contest_invitations_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class ContestInvitationsController < ApplicationController + before_action :set_container + before_action :set_contest_description + before_action :set_contest_instance + before_action :authorize_contest_instance + + def create + emails = parse_emails(params[:emails].presence || params.dig(:contest_invitation, :email)) + created = 0 + skipped = [] + + emails.each do |email| + invitation = @contest_instance.contest_invitations.find_or_initialize_by(email: email) + if invitation.new_record? + invitation.invited_by = current_user + if invitation.save + created += 1 + else + skipped << email + end + else + skipped << email + end + end + + notice_parts = [] + notice_parts << "Added #{created} invite#{'s' unless created == 1}." if created.positive? + notice_parts << "Skipped #{skipped.size} duplicate or invalid email#{'s' unless skipped.size == 1}." if skipped.any? + + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + notice: notice_parts.presence&.join(' ') || 'No invitations were added.' + end + + def destroy + invitation = @contest_instance.contest_invitations.find(params[:id]) + invitation.destroy + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + notice: 'Invitee removed.' + end + + private + + def set_container + @container = policy_scope(Container).find(params[:container_id]) + end + + def set_contest_description + @contest_description = @container.contest_descriptions.find(params[:contest_description_id]) + end + + def set_contest_instance + @contest_instance = @contest_description.contest_instances.find(params[:contest_instance_id]) + end + + def authorize_contest_instance + authorize @contest_instance, :manage_invitations? + end + + def parse_emails(raw) + Array(raw.to_s.split(/[\s,;]+/)).map { |email| email.strip.downcase }.reject(&:blank?).uniq + end +end diff --git a/app/controllers/contest_invites_controller.rb b/app/controllers/contest_invites_controller.rb new file mode 100644 index 00000000..e4f637c3 --- /dev/null +++ b/app/controllers/contest_invites_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class ContestInvitesController < ApplicationController + def show + @contest_instance = ContestInstance.find_by!(access_token: params[:token]) + + unless current_user.profile + redirect_to new_profile_path, alert: 'Please create your profile before accessing this contest.' + return + end + + if @contest_instance.private_visibility? && @contest_instance.invite_list? && !@contest_instance.invited?(current_user) + redirect_to applicant_dashboard_path, alert: 'You are not invited to submit to this contest.' + return + end + + redeem_contest_instance!(@contest_instance) + + unless @contest_instance.open? + redirect_to applicant_dashboard_path, alert: 'This contest is not currently open for submissions.' + return + end + + unless @contest_instance.available_for_profile?(current_user.profile) + redirect_to applicant_dashboard_path, + alert: 'You are not eligible to submit to this contest (class level or entry limit).' + return + end + + redirect_to new_entry_path(contest_instance_id: @contest_instance.id), + notice: "Welcome to #{@contest_instance.contest_description.name}." + rescue ActiveRecord::RecordNotFound + redirect_to applicant_dashboard_path, alert: 'Invalid or expired contest invite link.' + end +end diff --git a/app/javascript/controllers/access_mode_controller.js b/app/javascript/controllers/access_mode_controller.js new file mode 100644 index 00000000..1d6e89da --- /dev/null +++ b/app/javascript/controllers/access_mode_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus" + +// Toggles invite-list management UI based on access mode selection +export default class extends Controller { + static targets = ["inviteListSection"] + static values = { inviteListMode: { type: String, default: "invite_list" } } + + toggle(event) { + const mode = event.target.value + if (!this.hasInviteListSectionTarget) return + + if (mode === this.inviteListModeValue) { + this.inviteListSectionTarget.classList.remove("d-none") + } else { + this.inviteListSectionTarget.classList.add("d-none") + } + } +} diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js new file mode 100644 index 00000000..7fbbdb83 --- /dev/null +++ b/app/javascript/controllers/clipboard_controller.js @@ -0,0 +1,30 @@ +import { Controller } from "@hotwired/stimulus" + +// Copies text from a target input/element to the clipboard +export default class extends Controller { + static targets = ["source", "feedback"] + static values = { successMessage: { type: String, default: "Copied!" } } + + async copy(event) { + event.preventDefault() + const text = this.sourceTarget.value || this.sourceTarget.textContent + + try { + await navigator.clipboard.writeText(text) + this.showFeedback(this.successMessageValue) + } catch (_error) { + this.sourceTarget.select?.() + this.showFeedback("Select and copy manually") + } + } + + showFeedback(message) { + if (!this.hasFeedbackTarget) return + + this.feedbackTarget.textContent = message + this.feedbackTarget.classList.remove("d-none") + window.setTimeout(() => { + this.feedbackTarget.classList.add("d-none") + }, 2000) + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 896a70fa..eaa60a70 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -7,9 +7,15 @@ import { application } from "./application" import CharacterCounterController from "./character_counter_controller" application.register("character-counter", CharacterCounterController) +import AccessModeController from "./access_mode_controller" +application.register("access-mode", AccessModeController) + import CheckboxselectController from "./checkboxselect_controller" application.register("checkboxselect", CheckboxselectController) +import ClipboardController from "./clipboard_controller" +application.register("clipboard", ClipboardController) + import CommentsCounterController from "./comments_counter_controller" application.register("comments-counter", CommentsCounterController) diff --git a/app/models/contest_instance.rb b/app/models/contest_instance.rb index 934af75c..89a86d28 100644 --- a/app/models/contest_instance.rb +++ b/app/models/contest_instance.rb @@ -3,6 +3,8 @@ # Table name: contest_instances # # id :bigint not null, primary key +# access_mode :string(255) default("capability_url"), not null +# access_token :string(255) not null # active :boolean default(FALSE), not null # archived :boolean default(FALSE), not null # course_requirement_description :text(65535) @@ -25,6 +27,7 @@ # # contest_description_id_idx (contest_description_id) # id_unq_idx (id) UNIQUE +# index_contest_instances_on_access_token (access_token) UNIQUE # index_contest_instances_on_contest_description_id (contest_description_id) # # Foreign Keys @@ -32,6 +35,13 @@ # fk_rails_... (contest_description_id => contest_descriptions.id) # class ContestInstance < ApplicationRecord + ACCESS_MODES = { + capability_url: 'capability_url', + invite_list: 'invite_list' + }.freeze + + has_secure_token :access_token + # Associations belongs_to :contest_description has_many :class_level_requirements, dependent: :destroy @@ -42,6 +52,9 @@ class ContestInstance < ApplicationRecord has_many :judging_assignments, dependent: :restrict_with_error has_many :judges, through: :judging_assignments, source: :user has_many :judging_rounds, dependent: :restrict_with_error + has_many :contest_invitations, dependent: :destroy + + enum :access_mode, ACCESS_MODES, default: :capability_url # Validations validates :date_open, presence: true @@ -52,6 +65,7 @@ class ContestInstance < ApplicationRecord validates :has_course_requirement, inclusion: { in: [ true, false ] } validates :recletter_required, inclusion: { in: [ true, false ] } validates :transcript_required, inclusion: { in: [ true, false ] } + validates :access_mode, presence: true, inclusion: { in: ACCESS_MODES.values } validate :must_have_at_least_one_class_level_requirement validate :must_have_at_least_one_category validate :only_one_active_per_contest_description @@ -101,6 +115,44 @@ def open? active && Time.current.between?(date_open, date_closed) end + def container + contest_description.container + end + + def private_visibility? + container.visibility.kind == 'Private' + end + + def public_visibility? + !private_visibility? + end + + def invited?(user) + return false if user&.email.blank? + + contest_invitations.for_email(user.email).exists? + end + + def available_for_profile?(profile) + return false if profile.blank? + return false unless class_levels.exists?(id: profile.class_level_id) + + entries.active.where(profile: profile).count < maximum_number_entries_per_applicant + end + + def eligible_for_submission?(profile) + open? && available_for_profile?(profile) + end + + def access_granted_for?(user, redeemed_token: nil) + return true if public_visibility? + return false if redeemed_token.blank? || redeemed_token != access_token + return true if capability_url? + return invited?(user) if invite_list? + + false + end + def judging_open?(user = nil) current_round = judging_rounds.where(active: true) .where('start_date <= ? AND end_date >= ?', Time.zone.now, Time.zone.now) diff --git a/app/models/contest_invitation.rb b/app/models/contest_invitation.rb new file mode 100644 index 00000000..95231640 --- /dev/null +++ b/app/models/contest_invitation.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: contest_invitations +# +# id :bigint not null, primary key +# email :string(255) not null +# created_at :datetime not null +# updated_at :datetime not null +# contest_instance_id :bigint not null +# invited_by_id :bigint +# +# Indexes +# +# index_contest_invitations_on_contest_instance_id (contest_instance_id) +# index_contest_invitations_on_instance_and_email (contest_instance_id,email) UNIQUE +# index_contest_invitations_on_invited_by_id (invited_by_id) +# +# Foreign Keys +# +# fk_rails_... (contest_instance_id => contest_instances.id) +# fk_rails_... (invited_by_id => users.id) +# +class ContestInvitation < ApplicationRecord + belongs_to :contest_instance + belongs_to :invited_by, class_name: 'User', optional: true + + before_validation :normalize_email + + validates :email, presence: true, + format: { with: URI::MailTo::EMAIL_REGEXP }, + uniqueness: { scope: :contest_instance_id, case_sensitive: false } + + scope :for_email, ->(email) { where(email: email.to_s.strip.downcase) } + + private + + def normalize_email + self.email = email.to_s.strip.downcase.presence + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 00000000..337d09a3 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Current < ActiveSupport::CurrentAttributes + # Hash of contest_instance_id (string) => access_token that was redeemed + attribute :redeemed_contest_instances +end diff --git a/app/policies/contest_instance_policy.rb b/app/policies/contest_instance_policy.rb index c55c4f97..5c54890a 100644 --- a/app/policies/contest_instance_policy.rb +++ b/app/policies/contest_instance_policy.rb @@ -98,4 +98,12 @@ def notify_completed? return false unless record.judging_open?(user) record.judges.include?(user) end + + def manage_invitations? + user&.has_container_role?(record.contest_description.container) || axis_mundi? + end + + def regenerate_access_token? + manage_invitations? + end end diff --git a/app/policies/entry_policy.rb b/app/policies/entry_policy.rb index 1e19f27d..90f1a140 100644 --- a/app/policies/entry_policy.rb +++ b/app/policies/entry_policy.rb @@ -1,6 +1,10 @@ class EntryPolicy < ApplicationPolicy def create? - (record.profile.user == user && record.contest_instance.open?) || axis_mundi? + return true if axis_mundi? + return false unless record.profile&.user == user + return false unless record.contest_instance.eligible_for_submission?(record.profile) + + private_access_allowed? end def soft_delete? @@ -61,4 +65,14 @@ def resolve end end end + + private + + def private_access_allowed? + contest_instance = record.contest_instance + return true if contest_instance.public_visibility? + + redeemed_token = Current.redeemed_contest_instances&.dig(contest_instance.id.to_s) + contest_instance.access_granted_for?(user, redeemed_token: redeemed_token) + end end diff --git a/app/views/contest_instances/_contest_instance.html.erb b/app/views/contest_instances/_contest_instance.html.erb index e9ca645c..107133dd 100644 --- a/app/views/contest_instances/_contest_instance.html.erb +++ b/app/views/contest_instances/_contest_instance.html.erb @@ -1,5 +1,7 @@
+ <%= render 'contest_instances/private_access', contest_instance: contest_instance %> + <%# Status Section %>
@@ -13,6 +15,12 @@
+ <% if contest_instance.private_visibility? %> + Access: + + <%= contest_instance.capability_url? ? 'Link' : 'Invite list' %> + + <% end %>
diff --git a/app/views/contest_instances/_private_access.html.erb b/app/views/contest_instances/_private_access.html.erb new file mode 100644 index 00000000..637c7755 --- /dev/null +++ b/app/views/contest_instances/_private_access.html.erb @@ -0,0 +1,121 @@ +<%# Private collection share / invite access controls for a contest instance %> +<% if contest_instance.private_visibility? && policy(contest_instance).manage_invitations? %> +
+
+
+
Private Contest Access
+

+ This collection is Private. Applicants will not see it on the dashboard. + Share the invite link below so invited applicants can submit. +

+
+ Private +
+ + <%= simple_form_for([@container, @contest_description, contest_instance], html: { class: 'mb-3' }) do |f| %> + <%= f.input :access_mode, + as: :radio_buttons, + collection: [ + ['Anyone with the link', 'capability_url'], + ['Invite list only', 'invite_list'] + ], + label: 'Who can submit?', + wrapper: :vertical_collection_inline, + input_html: { + data: { + action: 'change->access-mode#toggle' + } + } %> + <%= f.button :submit, 'Update access mode', class: 'btn btn-sm btn-outline-primary' %> + <% end %> + +
+ +
+ + +
+
+
+ Link works only while this instance is active and open. Regenerating revokes previous links. +
+ <%= button_to 'Regenerate link', + regenerate_access_token_container_contest_description_contest_instance_path( + @container, @contest_description, contest_instance + ), + method: :post, + class: 'btn btn-sm btn-outline-danger mt-2', + data: { + turbo_confirm: 'Regenerate the invite link? Previous links will stop working.' + } %> +
+ +
+
+
+ Invite list + <%= contest_instance.contest_invitations.count %> +
+

+ Only people with these email addresses (matching their sign-in email) can submit, + and they still need the invite link. +

+ + <%= form_with url: container_contest_description_contest_instance_contest_invitations_path( + @container, @contest_description, contest_instance + ), method: :post, local: true, class: 'mb-3' do |f| %> +
+ <%= f.label :emails, 'Add emails (one per line or comma-separated)', class: 'form-label' %> + <%= text_area_tag :emails, nil, class: 'form-control', rows: 3, + placeholder: "applicant1@umich.edu\napplicant2@umich.edu" %> +
+ <%= f.submit 'Add invitees', class: 'btn btn-sm btn-primary' %> + <% end %> + + <% if contest_instance.contest_invitations.any? %> +
+ + + + + + + + + + <% contest_instance.contest_invitations.order(:email).each do |invitation| %> + + + + + + <% end %> + +
EmailAdded
<%= invitation.email %><%= invitation.created_at.strftime('%b %d, %Y') %> + <%= button_to 'Remove', + container_contest_description_contest_instance_contest_invitation_path( + @container, @contest_description, contest_instance, invitation + ), + method: :delete, + class: 'btn btn-sm btn-link text-danger p-0', + data: { turbo_confirm: 'Remove this invitee?' } %> +
+
+ <% else %> + No invitees yet. + <% end %> +
+
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 647d1c10..3a506c21 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,9 @@ end end + # Unguessable applicant invite URL for private contest instances + get '/c/:token', to: 'contest_invites#show', as: :contest_invite + devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks', sessions: 'users/sessions' } devise_scope :user do @@ -46,7 +49,9 @@ get :export_entries get :export_round_results patch :deactivate + post :regenerate_access_token end + resources :contest_invitations, only: [ :create, :destroy ] resources :judging_rounds do member do patch :activate diff --git a/db/migrate/20260728162719_add_access_fields_to_contest_instances.rb b/db/migrate/20260728162719_add_access_fields_to_contest_instances.rb new file mode 100644 index 00000000..8077c0c2 --- /dev/null +++ b/db/migrate/20260728162719_add_access_fields_to_contest_instances.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddAccessFieldsToContestInstances < ActiveRecord::Migration[8.1] + def up + add_column :contest_instances, :access_token, :string + add_column :contest_instances, :access_mode, :string, null: false, default: 'capability_url' + add_index :contest_instances, :access_token, unique: true + + # Backfill unguessable tokens for existing instances + say_with_time 'Backfilling contest instance access tokens' do + ContestInstance.reset_column_information + ContestInstance.unscoped.find_each do |contest_instance| + contest_instance.update_columns(access_token: SecureRandom.base58(24)) + end + end + + change_column_null :contest_instances, :access_token, false + end + + def down + remove_index :contest_instances, :access_token + remove_column :contest_instances, :access_token + remove_column :contest_instances, :access_mode + end +end diff --git a/db/migrate/20260728162720_create_contest_invitations.rb b/db/migrate/20260728162720_create_contest_invitations.rb new file mode 100644 index 00000000..0a8c37f0 --- /dev/null +++ b/db/migrate/20260728162720_create_contest_invitations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateContestInvitations < ActiveRecord::Migration[8.1] + def change + create_table :contest_invitations do |t| + t.references :contest_instance, null: false, foreign_key: true + t.string :email, null: false + t.references :invited_by, foreign_key: { to_table: :users } + + t.timestamps + end + + add_index :contest_invitations, [ :contest_instance_id, :email ], unique: true, + name: 'index_contest_invitations_on_instance_and_email' + end +end diff --git a/db/schema.rb b/db/schema.rb index de7e0f1f..20e8e9af 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.1].define(version: 2026_01_13_165213) do +ActiveRecord::Schema[8.1].define(version: 2026_07_28_162720) do create_table "action_text_rich_texts", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.text "body", size: :long t.datetime "created_at", null: false @@ -141,6 +141,8 @@ end create_table "contest_instances", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "access_mode", default: "capability_url", null: false + t.string "access_token", null: false t.boolean "active", default: false, null: false t.boolean "archived", default: false, null: false t.bigint "contest_description_id", null: false @@ -158,11 +160,23 @@ t.boolean "require_pen_name", default: false, null: false t.boolean "transcript_required", default: false, null: false t.datetime "updated_at", null: false + t.index ["access_token"], name: "index_contest_instances_on_access_token", unique: true t.index ["contest_description_id"], name: "contest_description_id_idx" t.index ["contest_description_id"], name: "index_contest_instances_on_contest_description_id" t.index ["id"], name: "id_unq_idx", unique: true end + create_table "contest_invitations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.bigint "contest_instance_id", null: false + t.datetime "created_at", null: false + t.string "email", null: false + t.bigint "invited_by_id" + t.datetime "updated_at", null: false + t.index ["contest_instance_id", "email"], name: "index_contest_invitations_on_instance_and_email", unique: true + t.index ["contest_instance_id"], name: "index_contest_invitations_on_contest_instance_id" + t.index ["invited_by_id"], name: "index_contest_invitations_on_invited_by_id" + end + create_table "departments", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.text "dept_description" @@ -370,6 +384,8 @@ add_foreign_key "containers", "visibilities" add_foreign_key "contest_descriptions", "containers" add_foreign_key "contest_instances", "contest_descriptions" + add_foreign_key "contest_invitations", "contest_instances" + add_foreign_key "contest_invitations", "users", column: "invited_by_id" add_foreign_key "entries", "categories" add_foreign_key "entries", "contest_instances" add_foreign_key "entries", "profiles" diff --git a/spec/controllers/contest_instances_regenerate_access_token_spec.rb b/spec/controllers/contest_instances_regenerate_access_token_spec.rb new file mode 100644 index 00000000..69da7e40 --- /dev/null +++ b/spec/controllers/contest_instances_regenerate_access_token_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ContestInstancesController, type: :controller do + describe 'POST #regenerate_access_token' do + let(:admin) { create(:user) } + let(:admin_role) { create(:role, kind: 'Collection Administrator') } + let(:container) { create(:container, :private) } + let(:description) { create(:contest_description, :active, container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: description) } + + before do + create(:assignment, user: admin, container: container, role: admin_role) + sign_in admin + end + + it 'regenerates the access token' do + old_token = contest_instance.access_token + + post :regenerate_access_token, params: { + container_id: container.id, + contest_description_id: description.id, + id: contest_instance.id + } + + expect(contest_instance.reload.access_token).not_to eq(old_token) + expect(response).to redirect_to( + container_contest_description_contest_instance_path(container, description, contest_instance) + ) + expect(flash[:notice]).to match(/regenerated/i) + end + end +end diff --git a/spec/controllers/contest_invitations_controller_spec.rb b/spec/controllers/contest_invitations_controller_spec.rb new file mode 100644 index 00000000..7dc03747 --- /dev/null +++ b/spec/controllers/contest_invitations_controller_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ContestInvitationsController, type: :controller do + let(:admin) { create(:user) } + let(:admin_role) { create(:role, kind: 'Collection Administrator') } + let(:container) { create(:container, :private) } + let(:description) { create(:contest_description, :active, container: container) } + let(:contest_instance) { create(:contest_instance, :invite_list, contest_description: description) } + + before do + create(:assignment, user: admin, container: container, role: admin_role) + sign_in admin + end + + describe 'POST #create' do + it 'adds invitees from a bulk email list' do + expect { + post :create, params: { + container_id: container.id, + contest_description_id: description.id, + contest_instance_id: contest_instance.id, + emails: "one@umich.edu\ntwo@umich.edu, three@umich.edu" + } + }.to change(ContestInvitation, :count).by(3) + + expect(response).to redirect_to( + container_contest_description_contest_instance_path(container, description, contest_instance) + ) + end + end + + describe 'DELETE #destroy' do + let!(:invitation) { create(:contest_invitation, contest_instance: contest_instance) } + + it 'removes an invitee' do + expect { + delete :destroy, params: { + container_id: container.id, + contest_description_id: description.id, + contest_instance_id: contest_instance.id, + id: invitation.id + } + }.to change(ContestInvitation, :count).by(-1) + end + end +end diff --git a/spec/controllers/contest_invites_controller_spec.rb b/spec/controllers/contest_invites_controller_spec.rb new file mode 100644 index 00000000..243db2e1 --- /dev/null +++ b/spec/controllers/contest_invites_controller_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ContestInvitesController, type: :controller do + let(:class_level) { create(:class_level) } + let(:user) { create(:user) } + let!(:profile) { create(:profile, user: user, class_level: class_level) } + + before { sign_in user } + + describe 'GET #show' do + context 'with a private capability_url contest' do + let(:container) { create(:container, :private) } + let(:description) { create(:contest_description, :active, container: container) } + let(:contest_instance) do + create(:contest_instance, contest_description: description).tap do |ci| + ci.class_levels = [ class_level ] + ci.save! + end + end + + it 'redeems the token in session and redirects to new entry' do + get :show, params: { token: contest_instance.access_token } + + expect(session[:redeemed_contest_instances][contest_instance.id.to_s]) + .to eq(contest_instance.access_token) + expect(response).to redirect_to(new_entry_path(contest_instance_id: contest_instance.id)) + end + end + + context 'with a private invite_list contest' do + let(:container) { create(:container, :private) } + let(:description) { create(:contest_description, :active, container: container) } + let(:contest_instance) do + create(:contest_instance, :invite_list, contest_description: description).tap do |ci| + ci.class_levels = [ class_level ] + ci.save! + end + end + + it 'denies access when the user is not invited' do + get :show, params: { token: contest_instance.access_token } + + expect(response).to redirect_to(applicant_dashboard_path) + expect(flash[:alert]).to match(/not invited/i) + expect(session[:redeemed_contest_instances]).to be_blank + end + + it 'allows access when the user is invited' do + create(:contest_invitation, contest_instance: contest_instance, email: user.email) + get :show, params: { token: contest_instance.access_token } + + expect(response).to redirect_to(new_entry_path(contest_instance_id: contest_instance.id)) + end + end + + context 'with an invalid token' do + it 'redirects with an alert' do + get :show, params: { token: 'not-a-real-token' } + + expect(response).to redirect_to(applicant_dashboard_path) + expect(flash[:alert]).to match(/invalid or expired/i) + end + end + + context 'when the user has no profile' do + let(:user_without_profile) { create(:user) } + let(:contest_instance) { create(:contest_instance) } + + before { sign_in user_without_profile } + + it 'redirects to profile creation' do + get :show, params: { token: contest_instance.access_token } + + expect(response).to redirect_to(new_profile_path) + end + end + end +end diff --git a/spec/factories/containers.rb b/spec/factories/containers.rb index 855bc2f0..16c19635 100644 --- a/spec/factories/containers.rb +++ b/spec/factories/containers.rb @@ -29,5 +29,13 @@ visibility notes { "Notes for #{name}" } contact_email { "contact@example.com" } + + trait :private do + association :visibility, :private + end + + trait :public do + association :visibility, :public + end end end diff --git a/spec/factories/contest_instances.rb b/spec/factories/contest_instances.rb index 97800378..f6c4d062 100644 --- a/spec/factories/contest_instances.rb +++ b/spec/factories/contest_instances.rb @@ -3,6 +3,8 @@ # Table name: contest_instances # # id :bigint not null, primary key +# access_mode :string(255) default("capability_url"), not null +# access_token :string(255) not null # active :boolean default(FALSE), not null # archived :boolean default(FALSE), not null # course_requirement_description :text(65535) @@ -25,6 +27,7 @@ # # contest_description_id_idx (contest_description_id) # id_unq_idx (id) UNIQUE +# index_contest_instances_on_access_token (access_token) UNIQUE # index_contest_instances_on_contest_description_id (contest_description_id) # # Foreign Keys @@ -78,6 +81,14 @@ active { false } end + trait :invite_list do + access_mode { 'invite_list' } + end + + trait :capability_url do + access_mode { 'capability_url' } + end + # For testing validation failures trait :without_class_levels do after(:build) do |contest_instance| diff --git a/spec/factories/contest_invitations.rb b/spec/factories/contest_invitations.rb new file mode 100644 index 00000000..fb26c8a5 --- /dev/null +++ b/spec/factories/contest_invitations.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :contest_invitation do + contest_instance + sequence(:email) { |n| "invitee#{n}@umich.edu" } + association :invited_by, factory: :user + end +end diff --git a/spec/models/contest_instance_access_spec.rb b/spec/models/contest_instance_access_spec.rb new file mode 100644 index 00000000..5db15528 --- /dev/null +++ b/spec/models/contest_instance_access_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ContestInstance, type: :model do + describe 'access token' do + it 'generates an access_token on create' do + contest_instance = create(:contest_instance) + expect(contest_instance.access_token).to be_present + expect(contest_instance.access_token.length).to be >= 24 + end + + it 'defaults access_mode to capability_url' do + contest_instance = create(:contest_instance) + expect(contest_instance).to be_capability_url + end + end + + describe '#private_visibility?' do + it 'is true when the container is Private' do + container = create(:container, :private) + description = create(:contest_description, :active, container: container) + contest_instance = create(:contest_instance, contest_description: description) + + expect(contest_instance).to be_private_visibility + expect(contest_instance).not_to be_public_visibility + end + + it 'is false when the container is Public' do + contest_instance = create(:contest_instance) + expect(contest_instance).to be_public_visibility + end + end + + describe '#available_for_profile?' do + let(:class_level) { create(:class_level) } + let(:contest_instance) do + create(:contest_instance).tap do |ci| + ci.class_levels = [ class_level ] + ci.save! + end + end + let(:profile) { create(:profile, class_level: class_level) } + + it 'returns true when class level matches and under entry cap' do + expect(contest_instance.available_for_profile?(profile)).to be true + end + + it 'returns false when class level does not match' do + other_profile = create(:profile, class_level: create(:class_level)) + expect(contest_instance.available_for_profile?(other_profile)).to be false + end + end + + describe '#access_granted_for?' do + let(:user) { create(:user) } + let(:container) { create(:container, :private) } + let(:description) { create(:contest_description, :active, container: container) } + let(:contest_instance) { create(:contest_instance, contest_description: description) } + + it 'allows public contests without a token' do + public_instance = create(:contest_instance) + expect(public_instance.access_granted_for?(user)).to be true + end + + it 'denies private contests without a matching redeemed token' do + expect(contest_instance.access_granted_for?(user, redeemed_token: nil)).to be false + expect(contest_instance.access_granted_for?(user, redeemed_token: 'wrong')).to be false + end + + it 'allows private capability_url contests with a matching token' do + expect( + contest_instance.access_granted_for?(user, redeemed_token: contest_instance.access_token) + ).to be true + end + + context 'with invite_list mode' do + let(:contest_instance) do + create(:contest_instance, :invite_list, contest_description: description) + end + + it 'denies when the user is not on the invite list' do + expect( + contest_instance.access_granted_for?(user, redeemed_token: contest_instance.access_token) + ).to be false + end + + it 'allows when the user is on the invite list' do + create(:contest_invitation, contest_instance: contest_instance, email: user.email) + expect( + contest_instance.access_granted_for?(user, redeemed_token: contest_instance.access_token) + ).to be true + end + end + end +end diff --git a/spec/models/contest_invitation_spec.rb b/spec/models/contest_invitation_spec.rb new file mode 100644 index 00000000..e497e73f --- /dev/null +++ b/spec/models/contest_invitation_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ContestInvitation, type: :model do + it 'normalizes email to lowercase' do + invitation = create(:contest_invitation, email: 'Applicant@Umich.Edu') + expect(invitation.email).to eq('applicant@umich.edu') + end + + it 'requires a unique email per contest instance' do + invitation = create(:contest_invitation) + duplicate = build(:contest_invitation, + contest_instance: invitation.contest_instance, + email: invitation.email.upcase) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email]).to be_present + end + + it 'allows the same email on different contest instances' do + invitation = create(:contest_invitation) + other = build(:contest_invitation, email: invitation.email) + + expect(other).to be_valid + end +end diff --git a/spec/policies/entry_policy_create_spec.rb b/spec/policies/entry_policy_create_spec.rb new file mode 100644 index 00000000..ba521cfb --- /dev/null +++ b/spec/policies/entry_policy_create_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe EntryPolicy do + describe '#create?' do + subject { described_class.new(user, entry) } + + let(:class_level) { create(:class_level) } + let(:user) { profile.user } + let(:profile) { create(:profile, class_level: class_level) } + let(:entry) { build(:entry, profile: profile, contest_instance: contest_instance) } + + before do + Current.redeemed_contest_instances = {} + end + + after { Current.reset } + + context 'for a public contest' do + let(:contest_instance) do + create(:contest_instance).tap do |ci| + ci.class_levels = [ class_level ] + ci.save! + end + end + + it { is_expected.to permit_action(:create) } + + context 'when class level does not match' do + let(:contest_instance) { create(:contest_instance) } + + it { is_expected.to forbid_action(:create) } + end + end + + context 'for a private capability_url contest' do + let(:container) { create(:container, :private) } + let(:description) { create(:contest_description, :active, container: container) } + let(:contest_instance) do + create(:contest_instance, contest_description: description).tap do |ci| + ci.class_levels = [ class_level ] + ci.save! + end + end + + it 'forbids create without a redeemed token' do + expect(subject).to forbid_action(:create) + end + + it 'permits create with a redeemed matching token' do + Current.redeemed_contest_instances = { + contest_instance.id.to_s => contest_instance.access_token + } + expect(subject).to permit_action(:create) + end + + it 'forbids create after token regeneration (stale session token)' do + old_token = contest_instance.access_token + Current.redeemed_contest_instances = { contest_instance.id.to_s => old_token } + contest_instance.regenerate_access_token + + expect(subject).to forbid_action(:create) + end + end + + context 'for a private invite_list contest' do + let(:container) { create(:container, :private) } + let(:description) { create(:contest_description, :active, container: container) } + let(:contest_instance) do + create(:contest_instance, :invite_list, contest_description: description).tap do |ci| + ci.class_levels = [ class_level ] + ci.save! + end + end + + before do + Current.redeemed_contest_instances = { + contest_instance.id.to_s => contest_instance.access_token + } + end + + it 'forbids create when the user is not invited' do + expect(subject).to forbid_action(:create) + end + + it 'permits create when the user is invited' do + create(:contest_invitation, contest_instance: contest_instance, email: user.email) + expect(subject).to permit_action(:create) + end + end + end +end From 8b660ee29d79c97b3852c32f02bed181dbbcd2c3 Mon Sep 17 00:00:00 2001 From: rsmokeUM Date: Wed, 29 Jul 2026 09:47:45 -0400 Subject: [PATCH 2/2] Email private contest invite links to invitees. Newly added invitees receive the link automatically, admins can resend to the full invite list, and bulk-created instances get fresh access tokens. Co-authored-by: Cursor --- .../bulk_contest_instances_controller.rb | 4 +- .../contest_invitations_controller.rb | 31 ++++++- app/mailers/contest_invite_mailer.rb | 22 +++++ app/policies/contest_instance_policy.rb | 4 + .../_private_access.html.erb | 19 +++- .../invite_to_submit.html.erb | 47 ++++++++++ .../invite_to_submit.text.erb | 24 +++++ config/routes.rb | 6 +- .../contest_invitations_controller_spec.rb | 87 +++++++++++++++++++ spec/mailers/contest_invite_mailer_spec.rb | 51 +++++++++++ .../previews/contest_invite_mailer_preview.rb | 22 +++++ 11 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 app/mailers/contest_invite_mailer.rb create mode 100644 app/views/contest_invite_mailer/invite_to_submit.html.erb create mode 100644 app/views/contest_invite_mailer/invite_to_submit.text.erb create mode 100644 spec/mailers/contest_invite_mailer_spec.rb create mode 100644 test/mailers/previews/contest_invite_mailer_preview.rb diff --git a/app/controllers/bulk_contest_instances_controller.rb b/app/controllers/bulk_contest_instances_controller.rb index 6dffc890..fd5daa6b 100644 --- a/app/controllers/bulk_contest_instances_controller.rb +++ b/app/controllers/bulk_contest_instances_controller.rb @@ -55,8 +55,10 @@ def create_contest_instances last_instance = description.contest_instances.order(created_at: :desc).first if last_instance - # Create from existing instance + # Create from existing instance. Assign a fresh access_token — + # dup copies the unique token, and has_secure_token only runs on initialize. new_instance = last_instance.dup + new_instance.access_token = ContestInstance.generate_unique_secure_token else # Create new instance with default values new_instance = description.contest_instances.new( diff --git a/app/controllers/contest_invitations_controller.rb b/app/controllers/contest_invitations_controller.rb index 2531bd15..ab5e255a 100644 --- a/app/controllers/contest_invitations_controller.rb +++ b/app/controllers/contest_invitations_controller.rb @@ -8,7 +8,7 @@ class ContestInvitationsController < ApplicationController def create emails = parse_emails(params[:emails].presence || params.dig(:contest_invitation, :email)) - created = 0 + created_invitations = [] skipped = [] emails.each do |email| @@ -16,7 +16,7 @@ def create if invitation.new_record? invitation.invited_by = current_user if invitation.save - created += 1 + created_invitations << invitation else skipped << email end @@ -25,8 +25,15 @@ def create end end + created_invitations.each do |invitation| + ContestInviteMailer.invite_to_submit(invitation).deliver_later + end + notice_parts = [] - notice_parts << "Added #{created} invite#{'s' unless created == 1}." if created.positive? + if created_invitations.any? + notice_parts << "Added #{created_invitations.size} invite#{'s' unless created_invitations.size == 1}." + notice_parts << "Invite email#{'s' unless created_invitations.size == 1} queued for #{created_invitations.size} new invitee#{'s' unless created_invitations.size == 1}." + end notice_parts << "Skipped #{skipped.size} duplicate or invalid email#{'s' unless skipped.size == 1}." if skipped.any? redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), @@ -40,6 +47,24 @@ def destroy notice: 'Invitee removed.' end + def email_all + authorize @contest_instance, :send_invite_emails? + + invitations = @contest_instance.contest_invitations + if invitations.none? + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + alert: 'There are no invitees to email.' + return + end + + invitations.find_each do |invitation| + ContestInviteMailer.invite_to_submit(invitation).deliver_later + end + + redirect_to container_contest_description_contest_instance_path(@container, @contest_description, @contest_instance), + notice: "Queued invite emails for #{invitations.count} invitee#{'s' unless invitations.count == 1}." + end + private def set_container diff --git a/app/mailers/contest_invite_mailer.rb b/app/mailers/contest_invite_mailer.rb new file mode 100644 index 00000000..bfdf97cf --- /dev/null +++ b/app/mailers/contest_invite_mailer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ContestInviteMailer < ApplicationMailer + def invite_to_submit(contest_invitation) + @invitation = contest_invitation + @contest_instance = contest_invitation.contest_instance + @contest_description = @contest_instance.contest_description + @container = @contest_description.container + @invite_url = contest_invite_url(token: @contest_instance.access_token) + @contact_email = @container.contact_email.presence || 'LSA Evaluate Support ' + @date_open = @contest_instance.date_open + @date_closed = @contest_instance.date_closed + + mail_options = { + to: @invitation.email, + subject: "You're invited to submit: #{@contest_description.name}" + } + mail_options[:reply_to] = @container.contact_email if @container.contact_email.present? + + mail(mail_options) + end +end diff --git a/app/policies/contest_instance_policy.rb b/app/policies/contest_instance_policy.rb index 5c54890a..af3fecf3 100644 --- a/app/policies/contest_instance_policy.rb +++ b/app/policies/contest_instance_policy.rb @@ -106,4 +106,8 @@ def manage_invitations? def regenerate_access_token? manage_invitations? end + + def send_invite_emails? + manage_invitations? + end end diff --git a/app/views/contest_instances/_private_access.html.erb b/app/views/contest_instances/_private_access.html.erb index 637c7755..7d97da8d 100644 --- a/app/views/contest_instances/_private_access.html.erb +++ b/app/views/contest_instances/_private_access.html.erb @@ -70,7 +70,7 @@

Only people with these email addresses (matching their sign-in email) can submit, - and they still need the invite link. + and they still need the invite link. Newly added invitees are emailed the link automatically.

<%= form_with url: container_contest_description_contest_instance_contest_invitations_path( @@ -85,6 +85,21 @@ <% end %> <% if contest_instance.contest_invitations.any? %> +
+ <%= button_to 'Email all invitees', + email_all_container_contest_description_contest_instance_contest_invitations_path( + @container, @contest_description, contest_instance + ), + method: :post, + class: 'btn btn-sm btn-outline-primary', + data: { + turbo_confirm: "Send the invite link to all #{contest_instance.contest_invitations.count} invitee#{'s' unless contest_instance.contest_invitations.count == 1}?" + } %> +
+ New invitees are emailed automatically when added. Use this to resend to everyone on the list. +
+
+
@@ -114,7 +129,7 @@
<% else %> - No invitees yet. + No invitees yet. Newly added invitees will receive an email with the invite link. <% end %>
diff --git a/app/views/contest_invite_mailer/invite_to_submit.html.erb b/app/views/contest_invite_mailer/invite_to_submit.html.erb new file mode 100644 index 00000000..4b8cf841 --- /dev/null +++ b/app/views/contest_invite_mailer/invite_to_submit.html.erb @@ -0,0 +1,47 @@ +
+

+ Invitation to Submit: <%= @contest_description.name %> +

+ +

+ You have been invited to submit an entry to + <%= @contest_description.name %> + (<%= @container.name %>). +

+ +
+

Submission Window

+

+ Opens: <%= I18n.l(@date_open, format: :long) %>
+ Closes: <%= I18n.l(@date_closed, format: :long) %> +

+
+ +

+ This contest is private and will not appear on the applicant dashboard. + Use the invite link below to sign in and submit. You must sign in with + <%= @invitation.email %> (or an account that uses that email). +

+ +
+ <%= link_to 'Open contest invite', @invite_url, + style: 'display: inline-block; padding: 12px 30px; background-color: #00274C; color: white; text-decoration: none; border-radius: 5px; font-size: 16px;' %> +
+ +

+ If the button does not work, copy and paste this link into your browser:
+ <%= @invite_url %> +

+ +

+ Questions? Contact + <%= @contact_email %>. +

+ +
+ +

+ This email was sent from LSA Evaluate.
+ University of Michigan +

+
diff --git a/app/views/contest_invite_mailer/invite_to_submit.text.erb b/app/views/contest_invite_mailer/invite_to_submit.text.erb new file mode 100644 index 00000000..c01d5285 --- /dev/null +++ b/app/views/contest_invite_mailer/invite_to_submit.text.erb @@ -0,0 +1,24 @@ +INVITATION TO SUBMIT: <%= @contest_description.name.upcase %> +================================================================================ + +You have been invited to submit an entry to <%= @contest_description.name %> +(<%= @container.name %>). + +SUBMISSION WINDOW +----------------- +Opens: <%= I18n.l(@date_open, format: :long) %> +Closes: <%= I18n.l(@date_closed, format: :long) %> + +This contest is private and will not appear on the applicant dashboard. +Use the invite link below to sign in and submit. You must sign in with +<%= @invitation.email %> (or an account that uses that email). + +OPEN CONTEST INVITE: +-------------------- +<%= @invite_url %> + +Questions? Contact <%= @contact_email %>. + +-------------------------------------------------------------------------------- +This email was sent from LSA Evaluate. +University of Michigan diff --git a/config/routes.rb b/config/routes.rb index 3a506c21..f67b0c0a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,7 +51,11 @@ patch :deactivate post :regenerate_access_token end - resources :contest_invitations, only: [ :create, :destroy ] + resources :contest_invitations, only: [ :create, :destroy ] do + collection do + post :email_all + end + end resources :judging_rounds do member do patch :activate diff --git a/spec/controllers/contest_invitations_controller_spec.rb b/spec/controllers/contest_invitations_controller_spec.rb index 7dc03747..2a2cf253 100644 --- a/spec/controllers/contest_invitations_controller_spec.rb +++ b/spec/controllers/contest_invitations_controller_spec.rb @@ -8,10 +8,12 @@ let(:container) { create(:container, :private) } let(:description) { create(:contest_description, :active, container: container) } let(:contest_instance) { create(:contest_instance, :invite_list, contest_description: description) } + let(:mail_delivery) { instance_double(ActionMailer::MessageDelivery, deliver_later: true) } before do create(:assignment, user: admin, container: container, role: admin_role) sign_in admin + allow(ContestInviteMailer).to receive(:invite_to_submit).and_return(mail_delivery) end describe 'POST #create' do @@ -29,6 +31,36 @@ container_contest_description_contest_instance_path(container, description, contest_instance) ) end + + it 'queues an invite email for each newly created invitee' do + expect(ContestInviteMailer).to receive(:invite_to_submit).exactly(2).times.and_return(mail_delivery) + expect(mail_delivery).to receive(:deliver_later).twice + + post :create, params: { + container_id: container.id, + contest_description_id: description.id, + contest_instance_id: contest_instance.id, + emails: "new1@umich.edu\nnew2@umich.edu" + } + + expect(flash[:notice]).to match(/queued/i) + end + + it 'does not email duplicate invitees that were skipped' do + create(:contest_invitation, contest_instance: contest_instance, email: 'existing@umich.edu') + + expect(ContestInviteMailer).to receive(:invite_to_submit).once.and_return(mail_delivery) + expect(mail_delivery).to receive(:deliver_later).once + + post :create, params: { + container_id: container.id, + contest_description_id: description.id, + contest_instance_id: contest_instance.id, + emails: "existing@umich.edu\nbrandnew@umich.edu" + } + + expect(contest_instance.contest_invitations.count).to eq(2) + end end describe 'DELETE #destroy' do @@ -45,4 +77,59 @@ }.to change(ContestInvitation, :count).by(-1) end end + + describe 'POST #email_all' do + let!(:invitation_one) do + create(:contest_invitation, contest_instance: contest_instance, email: 'one@umich.edu') + end + let!(:invitation_two) do + create(:contest_invitation, contest_instance: contest_instance, email: 'two@umich.edu') + end + + it 'queues invite emails for every invitee on the list' do + expect(ContestInviteMailer).to receive(:invite_to_submit).exactly(2).times.and_return(mail_delivery) + expect(mail_delivery).to receive(:deliver_later).twice + + post :email_all, params: { + container_id: container.id, + contest_description_id: description.id, + contest_instance_id: contest_instance.id + } + + expect(response).to redirect_to( + container_contest_description_contest_instance_path(container, description, contest_instance) + ) + expect(flash[:notice]).to match(/queued invite emails for 2/i) + end + + it 'alerts when there are no invitees' do + contest_instance.contest_invitations.destroy_all + + expect(ContestInviteMailer).not_to receive(:invite_to_submit) + + post :email_all, params: { + container_id: container.id, + contest_description_id: description.id, + contest_instance_id: contest_instance.id + } + + expect(flash[:alert]).to match(/no invitees/i) + end + + context 'when the user is not authorized' do + let(:other_user) { create(:user) } + + before { sign_in other_user } + + it 'redirects unauthorized users' do + post :email_all, params: { + container_id: container.id, + contest_description_id: description.id, + contest_instance_id: contest_instance.id + } + + expect(response).to redirect_to(root_path) + end + end + end end diff --git a/spec/mailers/contest_invite_mailer_spec.rb b/spec/mailers/contest_invite_mailer_spec.rb new file mode 100644 index 00000000..8faef5d5 --- /dev/null +++ b/spec/mailers/contest_invite_mailer_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ContestInviteMailer, type: :mailer do + describe '#invite_to_submit' do + let(:container) { create(:container, :private, contact_email: 'contest_admin@umich.edu') } + let(:contest_description) do + create(:contest_description, :active, container: container, name: 'Private Writing Prize') + end + let(:contest_instance) { create(:contest_instance, :invite_list, contest_description: contest_description) } + let(:invitation) do + create(:contest_invitation, contest_instance: contest_instance, email: 'invitee@umich.edu') + end + let(:mail) { described_class.invite_to_submit(invitation) } + + it 'renders the headers' do + expect(mail.subject).to eq("You're invited to submit: Private Writing Prize") + expect(mail.to).to eq([ 'invitee@umich.edu' ]) + expect(mail.from).to include(Rails.application.credentials.dig(:sendgrid, :mailer_sender)) + expect(mail.reply_to).to eq([ 'contest_admin@umich.edu' ]) + end + + it 'includes the contest name and collection name' do + expect(mail.body.encoded).to include('Private Writing Prize') + expect(mail.body.encoded).to include(container.name) + end + + it 'includes the invite URL with the access token' do + expect(mail.body.encoded).to include(contest_invite_url(token: contest_instance.access_token)) + end + + it 'includes the invitee email and contact email' do + expect(mail.body.encoded).to include('invitee@umich.edu') + expect(mail.body.encoded).to include('contest_admin@umich.edu') + end + + it 'includes the submission window dates' do + expect(mail.body.encoded).to include(I18n.l(contest_instance.date_open, format: :long)) + expect(mail.body.encoded).to include(I18n.l(contest_instance.date_closed, format: :long)) + end + + it 'falls back to default reply-to when container contact email is blank' do + container.contact_email = '' + container.save(validate: false) + + mail_without_contact = described_class.invite_to_submit(invitation) + expect(mail_without_contact.reply_to).to eq([ 'lsa-evaluate-support@umich.edu' ]) + end + end +end diff --git a/test/mailers/previews/contest_invite_mailer_preview.rb b/test/mailers/previews/contest_invite_mailer_preview.rb new file mode 100644 index 00000000..43327cbe --- /dev/null +++ b/test/mailers/previews/contest_invite_mailer_preview.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ContestInviteMailerPreview < ActionMailer::Preview + def invite_to_submit + invitation = ContestInvitation.includes(contest_instance: { contest_description: :container }).first || + create_sample_invitation + + ContestInviteMailer.invite_to_submit(invitation) + end + + private + + def create_sample_invitation + contest_instance = ContestInstance.includes(contest_description: :container).first + raise 'Create a contest instance before previewing ContestInviteMailer' if contest_instance.nil? + + ContestInvitation.create!( + contest_instance: contest_instance, + email: 'sample.invitee@umich.edu' + ) + end +end