diff --git a/app/controllers/course_settings_controller.rb b/app/controllers/course_settings_controller.rb
index 4a2cfe07..10116d88 100644
--- a/app/controllers/course_settings_controller.rb
+++ b/app/controllers/course_settings_controller.rb
@@ -72,7 +72,9 @@ def course_settings_params
:email_subject,
:email_template,
:enable_slack_webhook_url,
- :slack_webhook_url
+ :slack_webhook_url,
+ :pending_notification_frequency,
+ :pending_notification_email
)
end
diff --git a/app/javascript/controllers/course_settings_controller.js b/app/javascript/controllers/course_settings_controller.js
index 19a4ff03..7ada1020 100644
--- a/app/javascript/controllers/course_settings_controller.js
+++ b/app/javascript/controllers/course_settings_controller.js
@@ -1,12 +1,13 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
- static targets = ["emailField", "tab", "gradescopeField", "slackWebhookField"];
+ static targets = ["emailField", "tab", "gradescopeField", "slackWebhookField", "pendingNotificationEmail"];
connect() {
this.toggleEmailFields();
this.toggleSlackWebhookField();
this.toggleGradescopeFields();
+ this.togglePendingNotificationEmail();
const gradescopeToggle = document.getElementById('enable-gradescope');
if (gradescopeToggle) {
@@ -53,6 +54,15 @@ export default class extends Controller {
}
}
+ togglePendingNotificationEmail() {
+ const frequencySelect = document.getElementById('pending-notification-frequency');
+ const emailField = document.getElementById('pending-notification-email');
+
+ if (frequencySelect && emailField) {
+ emailField.disabled = !frequencySelect.value;
+ }
+ }
+
updateUrlParam(event) {
const tabName = event.currentTarget.dataset.tab;
const url = new URL(window.location);
diff --git a/app/jobs/pending_requests_notification_job.rb b/app/jobs/pending_requests_notification_job.rb
new file mode 100644
index 00000000..f94faa22
--- /dev/null
+++ b/app/jobs/pending_requests_notification_job.rb
@@ -0,0 +1,32 @@
+class PendingRequestsNotificationJob < ApplicationJob
+ queue_as :default
+
+ def perform(frequency)
+ CourseSettings.with_pending_notifications(frequency).includes(:course).find_each do |cs|
+ course = cs.course
+ pending_count = Request.where(course_id: course.id, status: 'pending').count
+ next if pending_count.zero?
+
+ requests_url = "#{ENV.fetch('APP_HOST', nil)}/courses/#{course.id}/requests"
+
+ EmailService.send_email(
+ to: cs.pending_notification_email,
+ from: ENV.fetch('DEFAULT_FROM_EMAIL'),
+ reply_to: cs.reply_email.presence || ENV.fetch('DEFAULT_FROM_EMAIL'),
+ subject_template: '{{pending_count}} Pending Extension Request{{plural}} - {{course_code}}',
+ body_template: "Hello,\n\nYou have {{pending_count}} pending extension request{{plural}} " \
+ "in {{course_name}} ({{course_code}}).\n\n" \
+ "Please review them at: {{requests_url}}\n\n" \
+ "Thank you,\nFlextensions",
+ mapping: {
+ 'pending_count' => pending_count.to_s,
+ 'plural' => pending_count == 1 ? '' : 's',
+ 'course_name' => course.course_name,
+ 'course_code' => course.course_code,
+ 'requests_url' => requests_url
+ },
+ deliver_later: false
+ )
+ end
+ end
+end
diff --git a/app/models/course_settings.rb b/app/models/course_settings.rb
index 10488a4c..0ef7cf6c 100644
--- a/app/models/course_settings.rb
+++ b/app/models/course_settings.rb
@@ -49,12 +49,25 @@ class CourseSettings < ApplicationRecord
{{course_name}} Staff
LIQUID
+ VALID_NOTIFICATION_FREQUENCIES = %w[daily weekly].freeze
+
belongs_to :course
+ before_validation -> { self.pending_notification_frequency = nil if pending_notification_frequency.blank? }
+ before_validation -> { self.pending_notification_email = nil if pending_notification_email.blank? }
before_save :ensure_system_user_for_auto_approval
+ before_save -> { self.pending_notification_email = nil if pending_notification_frequency.nil? }
validate :gradescope_url_is_valid, if: :enable_gradescope?
+ validates :pending_notification_frequency, inclusion: { in: VALID_NOTIFICATION_FREQUENCIES }, allow_nil: true
+ validates :pending_notification_email, presence: true, format: { with: /\A[^@\s]+@[^@\s]+\z/ },
+ if: -> { pending_notification_frequency.present? }
after_save :create_or_update_gradescope_link
+ scope :with_pending_notifications, ->(frequency) {
+ where(pending_notification_frequency: frequency)
+ .where.not(pending_notification_email: [ nil, '' ])
+ }
+
def automatic_approval_enabled?
return false unless enable_extensions?
diff --git a/app/views/courses/edit.html.erb b/app/views/courses/edit.html.erb
index 9214779e..f19b3fde 100644
--- a/app/views/courses/edit.html.erb
+++ b/app/views/courses/edit.html.erb
@@ -211,6 +211,34 @@
+
+
+
+ <%= select_tag 'course_settings[pending_notification_frequency]',
+ options_for_select(
+ [['No notifications', ''], ['Daily', 'daily'], ['Once weekly (Fridays)', 'weekly']],
+ @course.course_settings&.pending_notification_frequency
+ ),
+ class: 'form-select',
+ id: 'pending-notification-frequency',
+ data: { action: 'change->course-settings#togglePendingNotificationEmail' } %>
+
+
+
+
+
+
+ <%= email_field_tag 'course_settings[pending_notification_email]',
+ @course.course_settings&.pending_notification_email,
+ class: 'form-control',
+ id: 'pending-notification-email',
+ placeholder: 'instructor@berkeley.edu',
+ data: { course_settings_target: 'pendingNotificationEmail' },
+ disabled: @course.course_settings&.pending_notification_frequency.blank? %>
+
Weekly notifications are sent on Fridays at 5:00 PM PT.
+
+
+
<% if @course.course_settings&.enable_extensions %>
diff --git a/db/migrate/20260406175234_add_pending_notification_to_course_settings.rb b/db/migrate/20260406175234_add_pending_notification_to_course_settings.rb
new file mode 100644
index 00000000..fceca5e9
--- /dev/null
+++ b/db/migrate/20260406175234_add_pending_notification_to_course_settings.rb
@@ -0,0 +1,10 @@
+class AddPendingNotificationToCourseSettings < ActiveRecord::Migration[7.2]
+ def change
+ safety_assured do
+ change_table :course_settings, bulk: true do |t|
+ t.string :pending_notification_frequency, default: nil
+ t.string :pending_notification_email, default: nil
+ end
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 848df19c..51f871e5 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,9 +10,10 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_03_06_000001) do
+ActiveRecord::Schema[7.2].define(version: 2026_04_06_175234) do
create_schema "hypershield"
+
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -105,6 +106,8 @@
t.boolean "enable_gradescope", default: false
t.string "gradescope_course_url"
t.boolean "extend_late_due_date", default: true, null: false
+ t.string "pending_notification_frequency"
+ t.string "pending_notification_email"
t.index ["course_id"], name: "index_course_settings_on_course_id"
end
diff --git a/lib/tasks/notifications.rake b/lib/tasks/notifications.rake
new file mode 100644
index 00000000..978c5291
--- /dev/null
+++ b/lib/tasks/notifications.rake
@@ -0,0 +1,9 @@
+namespace :notifications do
+ desc 'Send pending request digest emails (usage: rake notifications:send_pending_digests[daily])'
+ task :send_pending_digests, [ :frequency ] => :environment do |_t, args|
+ frequency = args[:frequency]
+ abort 'Usage: rake notifications:send_pending_digests[daily|weekly]' unless %w[daily weekly].include?(frequency)
+
+ PendingRequestsNotificationJob.perform_now(frequency)
+ end
+end
diff --git a/spec/controllers/course_settings_controller_spec.rb b/spec/controllers/course_settings_controller_spec.rb
index c6ac03a8..79143970 100644
--- a/spec/controllers/course_settings_controller_spec.rb
+++ b/spec/controllers/course_settings_controller_spec.rb
@@ -118,6 +118,75 @@
end
end
+ describe 'pending notification params' do
+ before do
+ session[:user_id] = instructor.canvas_uid
+ UserToCourse.create!(user: instructor, course: course, role: 'instructor')
+ allow_any_instance_of(Course).to receive(:user_role).with(instructor).and_return('instructor')
+ CourseSettings.create!(course: course, enable_extensions: true)
+ end
+
+ it 'persists pending notification settings' do
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: 'daily',
+ pending_notification_email: 'prof@berkeley.edu'
+ },
+ tab: 'general'
+ }
+
+ settings = CourseSettings.find_by(course_id: course.id)
+ expect(settings.pending_notification_frequency).to eq('daily')
+ expect(settings.pending_notification_email).to eq('prof@berkeley.edu')
+ end
+
+ it 'normalizes blank frequency to nil' do
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: '',
+ pending_notification_email: ''
+ },
+ tab: 'general'
+ }
+
+ settings = CourseSettings.find_by(course_id: course.id)
+ expect(settings.pending_notification_frequency).to be_nil
+ end
+
+ it 'clears stored email when frequency is set to blank' do
+ settings = CourseSettings.find_by(course_id: course.id)
+ settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@berkeley.edu')
+
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: '',
+ pending_notification_email: ''
+ },
+ tab: 'general'
+ }
+
+ settings.reload
+ expect(settings.pending_notification_frequency).to be_nil
+ expect(settings.pending_notification_email).to be_nil
+ end
+
+ it 'shows validation errors for invalid email with frequency set' do
+ post :update, params: {
+ course_id: course.id,
+ course_settings: {
+ pending_notification_frequency: 'daily',
+ pending_notification_email: 'not-an-email'
+ },
+ tab: 'general'
+ }
+
+ expect(flash[:alert]).to include('Failed to update course settings:')
+ end
+ end
+
describe 'pending requests count' do
let(:assignment) do
# Create necessary related objects for Request
diff --git a/spec/factories/course_settings.rb b/spec/factories/course_settings.rb
index 2abddc97..682c96f6 100644
--- a/spec/factories/course_settings.rb
+++ b/spec/factories/course_settings.rb
@@ -36,5 +36,15 @@
association :course
enable_extensions { true }
auto_approve_days { 0 }
+
+ trait :with_daily_notifications do
+ pending_notification_frequency { 'daily' }
+ pending_notification_email { 'instructor@example.com' }
+ end
+
+ trait :with_weekly_notifications do
+ pending_notification_frequency { 'weekly' }
+ pending_notification_email { 'instructor@example.com' }
+ end
end
end
diff --git a/spec/jobs/pending_requests_notification_job_spec.rb b/spec/jobs/pending_requests_notification_job_spec.rb
new file mode 100644
index 00000000..54aa990c
--- /dev/null
+++ b/spec/jobs/pending_requests_notification_job_spec.rb
@@ -0,0 +1,86 @@
+require 'rails_helper'
+
+RSpec.describe PendingRequestsNotificationJob, type: :job do
+ let(:course) { create(:course, canvas_id: 'notif_123', course_name: 'CS 101', course_code: 'CS101') }
+ let(:student) { create(:user, canvas_uid: 'stu_notif_1', email: 'student_notif@example.com', name: 'Student') }
+ let(:lms) { Lms.first }
+ let(:course_to_lms) { CourseToLms.create!(course: course, lms: lms, external_course_id: 'ext_123') }
+ let(:assignment) do
+ Assignment.create!(
+ name: 'HW1',
+ course_to_lms: course_to_lms,
+ due_date: 3.days.from_now,
+ external_assignment_id: 'asgn_notif_1',
+ enabled: true
+ )
+ end
+
+ before do
+ ActionMailer::Base.delivery_method = :test
+ ActionMailer::Base.deliveries.clear
+ allow(ENV).to receive(:fetch).and_call_original
+ allow(ENV).to receive(:fetch).with('DEFAULT_FROM_EMAIL').and_return('flextensions@berkeley.edu')
+ allow(ENV).to receive(:fetch).with('APP_HOST', nil).and_return('http://localhost:3000')
+ end
+
+ describe '#perform' do
+ it 'sends email when course has matching frequency and pending requests' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
+ Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
+ reason: 'Need more time', requested_due_date: 5.days.from_now)
+
+ expect { described_class.perform_now('daily') }.to change { ActionMailer::Base.deliveries.count }.by(1)
+
+ mail = ActionMailer::Base.deliveries.last
+ expect(mail.to).to eq([ 'prof@example.com' ])
+ expect(mail.subject).to include('1 Pending Extension Request')
+ expect(mail.subject).to include('CS101')
+ expect(mail.body.encoded).to include("http://localhost:3000/courses/#{course.id}/requests")
+ end
+
+ it 'skips courses with zero pending requests' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
+
+ expect { described_class.perform_now('daily') }.not_to(change { ActionMailer::Base.deliveries.count })
+ end
+
+ it 'only sends to courses matching the given frequency' do
+ course.course_settings.update!(pending_notification_frequency: 'weekly', pending_notification_email: 'prof@example.com')
+ Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
+ reason: 'Need more time', requested_due_date: 5.days.from_now)
+
+ expect { described_class.perform_now('daily') }.not_to(change { ActionMailer::Base.deliveries.count })
+ end
+
+ it 'pluralizes correctly for multiple pending requests' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof@example.com')
+ 2.times do |i|
+ Request.create!(course: course, assignment: assignment,
+ user: create(:user, canvas_uid: "stu_multi_#{i}", email: "stu_multi_#{i}@example.com"),
+ status: 'pending', reason: 'Need time', requested_due_date: 5.days.from_now)
+ end
+
+ described_class.perform_now('daily')
+
+ mail = ActionMailer::Base.deliveries.last
+ expect(mail.subject).to include('2 Pending Extension Requests')
+ end
+
+ it 'sends separate emails to multiple courses' do
+ course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof1@example.com')
+ Request.create!(course: course, assignment: assignment, user: student, status: 'pending',
+ reason: 'Need time', requested_due_date: 5.days.from_now)
+
+ other_course = create(:course, canvas_id: 'notif_456', course_name: 'CS 201', course_code: 'CS201')
+ other_ctlms = CourseToLms.create!(course: other_course, lms: lms, external_course_id: 'ext_456')
+ other_assignment = Assignment.create!(name: 'HW2', course_to_lms: other_ctlms, due_date: 3.days.from_now,
+ external_assignment_id: 'asgn_notif_2', enabled: true)
+ other_course.course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'prof2@example.com')
+ other_student = create(:user, canvas_uid: 'stu_notif_2', email: 'stu_notif_2@example.com')
+ Request.create!(course: other_course, assignment: other_assignment, user: other_student, status: 'pending',
+ reason: 'Need time', requested_due_date: 5.days.from_now)
+
+ expect { described_class.perform_now('daily') }.to change { ActionMailer::Base.deliveries.count }.by(2)
+ end
+ end
+end
diff --git a/spec/models/course_settings_spec.rb b/spec/models/course_settings_spec.rb
index 31cd24e8..711a894a 100644
--- a/spec/models/course_settings_spec.rb
+++ b/spec/models/course_settings_spec.rb
@@ -177,6 +177,108 @@
end
end
+ describe 'pending notification validations' do
+ context 'pending_notification_frequency' do
+ it 'accepts nil' do
+ course_settings.pending_notification_frequency = nil
+ expect(course_settings).to be_valid
+ end
+
+ it 'accepts "daily"' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'test@example.com'
+ expect(course_settings).to be_valid
+ end
+
+ it 'accepts "weekly"' do
+ course_settings.pending_notification_frequency = 'weekly'
+ course_settings.pending_notification_email = 'test@example.com'
+ expect(course_settings).to be_valid
+ end
+
+ it 'rejects "monthly"' do
+ course_settings.pending_notification_frequency = 'monthly'
+ course_settings.pending_notification_email = 'test@example.com'
+ expect(course_settings).not_to be_valid
+ expect(course_settings.errors[:pending_notification_frequency]).to be_present
+ end
+ end
+
+ context 'pending_notification_email' do
+ it 'is required when frequency is set' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = nil
+ expect(course_settings).not_to be_valid
+ expect(course_settings.errors[:pending_notification_email]).to be_present
+ end
+
+ it 'validates email format when frequency is set' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'not-an-email'
+ expect(course_settings).not_to be_valid
+ expect(course_settings.errors[:pending_notification_email]).to be_present
+ end
+
+ it 'accepts a valid email when frequency is set' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'instructor@berkeley.edu'
+ expect(course_settings).to be_valid
+ end
+
+ it 'is not required when frequency is nil' do
+ course_settings.pending_notification_frequency = nil
+ course_settings.pending_notification_email = nil
+ expect(course_settings).to be_valid
+ end
+ end
+
+ context 'normalization' do
+ it 'normalizes empty string frequency to nil' do
+ course_settings.pending_notification_frequency = ''
+ course_settings.valid?
+ expect(course_settings.pending_notification_frequency).to be_nil
+ end
+
+ it 'normalizes empty string email to nil' do
+ course_settings.pending_notification_email = ''
+ course_settings.valid?
+ expect(course_settings.pending_notification_email).to be_nil
+ end
+
+ it 'clears email when frequency is set to nil on save' do
+ course_settings.pending_notification_frequency = 'daily'
+ course_settings.pending_notification_email = 'test@example.com'
+ course_settings.save!
+
+ course_settings.pending_notification_frequency = nil
+ course_settings.save!
+ course_settings.reload
+
+ expect(course_settings.pending_notification_email).to be_nil
+ end
+ end
+ end
+
+ describe '.with_pending_notifications' do
+ it 'returns records matching the given frequency with an email set' do
+ course_settings.update!(pending_notification_frequency: 'daily', pending_notification_email: 'a@example.com')
+
+ other_course = create(:course, canvas_id: 'other_123', course_name: 'Other', course_code: 'OTHER101')
+ other_course.course_settings.update!(pending_notification_frequency: 'weekly', pending_notification_email: 'b@example.com')
+
+ results = described_class.with_pending_notifications('daily')
+ expect(results).to include(course_settings)
+ expect(results).not_to include(other_course.course_settings)
+ end
+
+ it 'excludes records with nil email' do
+ course_settings.update_columns(pending_notification_frequency: 'daily', pending_notification_email: nil) # rubocop:disable Rails/SkipsModelValidations
+
+ results = described_class.with_pending_notifications('daily')
+ expect(results).not_to include(course_settings)
+ end
+ end
+
describe '#extract_gradescope_course_id' do
it 'extracts course ID from valid URL' do
url = 'https://www.gradescope.com/courses/123456'
diff --git a/spec/tasks/notifications_rake_spec.rb b/spec/tasks/notifications_rake_spec.rb
new file mode 100644
index 00000000..638d1c4e
--- /dev/null
+++ b/spec/tasks/notifications_rake_spec.rb
@@ -0,0 +1,21 @@
+require 'rails_helper'
+require 'rake'
+
+RSpec.describe 'notifications:send_pending_digests' do # rubocop:disable RSpec/DescribeClass
+ before(:all) do
+ Rails.application.load_tasks
+ end
+
+ it 'invokes PendingRequestsNotificationJob with valid frequency' do
+ expect(PendingRequestsNotificationJob).to receive(:perform_now).with('daily')
+ Rake::Task['notifications:send_pending_digests'].reenable
+ Rake::Task['notifications:send_pending_digests'].invoke('daily')
+ end
+
+ it 'aborts with usage message for invalid frequency' do
+ Rake::Task['notifications:send_pending_digests'].reenable
+ expect {
+ Rake::Task['notifications:send_pending_digests'].invoke('monthly')
+ }.to raise_error(SystemExit)
+ end
+end