From 673fcbc70f3fde712f4284eb0d83ecfb9a8ea7bf Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:12:49 +1000 Subject: [PATCH 01/57] feat: notifications --- app/api/api_root.rb | 2 + app/api/notifications_api.rb | 170 +++++++++++ app/api/task_comments_api.rb | 2 + app/mailers/notifications_mailer.rb | 20 ++ app/models/comments/task_comment.rb | 4 + app/models/comments/task_status_comment.rb | 4 - app/models/notification.rb | 268 ++++++++++++++++++ app/models/notification_preference.rb | 137 +++++++++ app/models/overseer_assessment.rb | 6 +- app/models/portfolio_evidence.rb | 13 +- app/models/project.rb | 1 + app/models/task.rb | 2 + app/models/tutor_note.rb | 1 + app/models/unit.rb | 24 +- app/models/user.rb | 11 + app/services/notification_group_builder.rb | 116 ++++++++ app/sidekiq/accept_overseer_job.rb | 1 + app/sidekiq/accept_submission_job.rb | 11 +- ...eate_pending_overseer_notifications_job.rb | 17 ++ app/sidekiq/notify_tutor_notes_job.rb | 2 +- app/sidekiq/poll_notification_digests_job.rb | 16 ++ app/sidekiq/prune_notifications_job.rb | 17 ++ .../send_immediate_notification_job.rb | 44 +++ app/sidekiq/send_notification_digest_job.rb | 56 ++++ .../notification_digest.html.erb | 100 +++++++ .../notification_digest.text.erb | 16 ++ config/schedule.yml | 12 + ...ifications_and_notification_preferences.rb | 54 ++++ db/schema.rb | 57 +++- test/api/comments/status_test.rb | 8 +- test/api/notifications_api_test.rb | 103 +++++++ test/factories/notifications.rb | 19 ++ test/mailers/unit_mail_test.rb | 12 +- test/models/notification_preference_test.rb | 111 ++++++++ test/models/notification_test.rb | 222 +++++++++++++++ test/sidekiq/notification_jobs_test.rb | 98 +++++++ 36 files changed, 1700 insertions(+), 57 deletions(-) create mode 100644 app/api/notifications_api.rb create mode 100644 app/models/notification.rb create mode 100644 app/models/notification_preference.rb create mode 100644 app/services/notification_group_builder.rb create mode 100644 app/sidekiq/create_pending_overseer_notifications_job.rb create mode 100644 app/sidekiq/poll_notification_digests_job.rb create mode 100644 app/sidekiq/prune_notifications_job.rb create mode 100644 app/sidekiq/send_immediate_notification_job.rb create mode 100644 app/sidekiq/send_notification_digest_job.rb create mode 100644 app/views/notifications_mailer/notification_digest.html.erb create mode 100644 app/views/notifications_mailer/notification_digest.text.erb create mode 100644 db/migrate/20260729043436_create_notifications_and_notification_preferences.rb create mode 100644 test/api/notifications_api_test.rb create mode 100644 test/factories/notifications.rb create mode 100644 test/models/notification_preference_test.rb create mode 100644 test/models/notification_test.rb create mode 100644 test/sidekiq/notification_jobs_test.rb diff --git a/app/api/api_root.rb b/app/api/api_root.rb index cb583d7a2c..760f2a038f 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -99,6 +99,7 @@ class ApiRoot < Grape::API mount UnitContentsApi mount UnitsApi mount TutorNotesApi + mount NotificationsApi mount D2lIntegrationApi::D2lApi mount D2lIntegrationApi::OauthPublicApi @@ -162,6 +163,7 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to DiscussionPromptsApi AuthenticationHelpers.add_auth_to OverseerStepsApi AuthenticationHelpers.add_auth_to TutorNotesApi + AuthenticationHelpers.add_auth_to NotificationsApi add_swagger_documentation \ base_path: nil, diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb new file mode 100644 index 0000000000..e191437998 --- /dev/null +++ b/app/api/notifications_api.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'grape' + +class NotificationsApi < Grape::API + helpers AuthenticationHelpers + + before do + authenticated? + end + + helpers do + def notification_scope + current_user + .received_notifications + .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) + end + + def unread_group_count + current_user.received_notifications.unread.pluck(:task_id, :unit_id, :kind, :metadata).map do |task_id, unit_id, kind, metadata| + if task_id.present? + "task:#{task_id}" + elsif kind == 'tutor_note' + "tutor-notes:#{unit_id}:#{metadata['unit_role_id']}" + else + "unit:#{unit_id}:#{kind}" + end + end.uniq.count + end + + def serialize_preference(preference) + { + id: preference.id, + unit: { + id: preference.unit.id, + code: preference.unit.code, + name: preference.unit.name + }, + email_categories: preference.email_categories, + email_frequency: preference.email_frequency, + email_time: preference.email_time, + email_weekday: preference.email_weekday, + timezone: preference.timezone, + next_digest_at: preference.next_digest_at, + last_digest_at: preference.last_digest_at + } + end + + def accessible_unit_ids + project_units = current_user.projects.where(enrolled: true).select(:unit_id) + role_units = current_user.unit_roles.select(:unit_id) + preference_units = current_user.notification_preferences.select(:unit_id) + + Unit.where(id: project_units).or(Unit.where(id: role_units)).or(Unit.where(id: preference_units)).pluck(:id) + end + end + + desc 'Get grouped notifications for the current user' + params do + optional :state, type: String, values: %w[all unread read], default: 'all' + optional :unit_id, type: Integer + optional :kinds, type: Array[String], values: Notification::KINDS + optional :query, type: String + optional :page, type: Integer, default: 1, values: ->(value) { value.positive? } + optional :per_page, type: Integer, default: 25, values: 1..50 + end + get '/notifications' do + scope = notification_scope + scope = scope.where(unit_id: params[:unit_id]) if params[:unit_id] + scope = scope.where(kind: params[:kinds]) if params[:kinds].present? + + scope = + case params[:state] + when 'unread' + scope.unread + when 'read' + scope.recently_read + else + scope.where('notifications.read_at IS NULL OR notifications.read_at >= ?', 30.days.ago) + end + + groups = NotificationGroupBuilder.new(scope).groups + if params[:query].present? + query = params[:query].downcase + groups.select! do |group| + [ + group[:summary], + group.dig(:unit, :code), + group.dig(:unit, :name), + group.dig(:task, :abbreviation), + group.dig(:task, :name), + group.dig(:task, :student_name) + ].compact.any? { |value| value.to_s.downcase.include?(query) } + end + end + + page = params[:page] + per_page = params[:per_page] + total = groups.count + + { + groups: groups.slice((page - 1) * per_page, per_page) || [], + page: page, + per_page: per_page, + total: total, + unread_count: unread_group_count + } + end + + desc 'Get the grouped unread notification count for the current user' + get '/notifications/unread_count' do + { count: unread_group_count } + end + + desc 'Mark selected notifications as read' + params do + requires :notification_ids, type: Array[Integer] + end + put '/notifications/read' do + scope = current_user.received_notifications.where(id: params[:notification_ids]).unread + count = scope.count + Notification.mark_read(scope) + { count: count } + end + + desc 'Mark all notifications as read' + params do + optional :unit_id, type: Integer + end + put '/notifications/read_all' do + scope = current_user.received_notifications.unread + scope = scope.where(unit_id: params[:unit_id]) if params[:unit_id] + count = scope.count + Notification.mark_read(scope) + { count: count } + end + + desc 'Get per-unit notification email preferences' + get '/notification_preferences' do + preferences = Unit.where(id: accessible_unit_ids).order(:code).map do |unit| + NotificationPreference.for(current_user, unit) + end + + preferences.map { |preference| serialize_preference(preference) } + end + + desc 'Update notification email preferences for a unit' + params do + requires :email_categories, type: Array[String], values: Notification::KINDS + requires :email_frequency, type: String, values: NotificationPreference::FREQUENCIES + requires :email_time, type: String + requires :email_weekday, type: Integer + requires :timezone, type: String + end + put '/notification_preferences/:unit_id' do + unit_id = params[:unit_id].to_i + error!({ error: 'You do not have access to notification settings for this unit' }, 403) unless accessible_unit_ids.include?(unit_id) + + preference = NotificationPreference.for(current_user, Unit.find(unit_id)) + preference.update!( + email_categories: params[:email_categories], + email_frequency: params[:email_frequency], + email_time: params[:email_time], + email_weekday: params[:email_weekday], + timezone: params[:timezone] + ) + + serialize_preference(preference) + end +end diff --git a/app/api/task_comments_api.rb b/app/api/task_comments_api.rb index cfe2500a87..9ad1c8be57 100644 --- a/app/api/task_comments_api.rb +++ b/app/api/task_comments_api.rb @@ -141,6 +141,7 @@ class TaskCommentsApi < Grape::API # mark every comment type except for DiscussionComments so we don't mark it as read. comments_to_mark_as_read = comments.where("TYPE is null OR TYPE != 'DiscussionComment'") task.mark_comments_as_read(current_user, comments_to_mark_as_read) + Notification.mark_task_read(current_user, task) else result = [] end @@ -267,6 +268,7 @@ class TaskCommentsApi < Grape::API task_comment = task.comments.find(params[:id]) task_comment.mark_as_unread(current_user) + Notification.reopen_for_source(task_comment, current_user) SessionTracker.record_assessment_activity( action: 'mark-comment-unread', diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index f4b0d255ad..f06b1b40ce 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -83,6 +83,26 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) mail(to: email_with_name, from: tutor_email, subject: subject) end + def notification_digest(preference, notifications) + return nil if preference.nil? || notifications.blank? + + add_general + @recipient = preference.user + @unit = preference.unit + @groups = NotificationGroupBuilder.new(notifications).groups + @notification_count = notifications.count + @notification_url = "#{@doubtfire_host}/notifications" + @sender = @unit.main_convenor_user + return nil if @sender.nil? + + subject = "#{@unit.code}: #{@notification_count} new #{'change'.pluralize(@notification_count)} across #{@groups.count} #{'notification'.pluralize(@groups.count)}" + mail( + to: %("#{@recipient.name}" <#{@recipient.email}>), + from: %("#{@sender.name}" <#{@sender.email}>), + subject: subject + ) + end + def discussion_deadline_approaching(task, sender, expiry_date) add_discussion_deadline_details(task, sender) @deadline = task.unit.formatted_discuss_timeout_date(expiry_date) diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb index c74883d014..4b7ca794d4 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -16,6 +16,7 @@ class TaskComment < ApplicationRecord belongs_to :recipient, class_name: 'User', optional: false has_many :comments_read_receipts, class_name: 'CommentsReadReceipts', dependent: :destroy, inverse_of: :task_comment + has_many :notifications, as: :source, dependent: :destroy # Can optionally be a reply to a comment belongs_to :task_comment, optional: true @@ -33,6 +34,9 @@ class TaskComment < ApplicationRecord after_create do mark_as_read(self.user) end + after_create_commit do + Notification.create_for_task_comment(self) + end # Delete action - before dependent association before_destroy :delete_associated_files diff --git a/app/models/comments/task_status_comment.rb b/app/models/comments/task_status_comment.rb index a224279db8..4e896ea91a 100644 --- a/app/models/comments/task_status_comment.rb +++ b/app/models/comments/task_status_comment.rb @@ -5,10 +5,6 @@ class TaskStatusComment < TaskComment self.content_type = :status end - after_create do - mark_as_read(self.recipient) - end - def serialize(user) json = super(user) json[:recipient_read_time] = nil diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 0000000000..3d518e1c2a --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +class Notification < ApplicationRecord + KINDS = %w[ + feedback_left + task_status_changed + overseer_failed + pdf_generation_failed + discuss_warning + discuss_expired + tutor_note + ].freeze + + DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze + INTERNAL_COMMENT_TYPES = %w[assessment checked_in discussed_in_class plan].freeze + + attribute :metadata, :json, default: -> { {} } + + belongs_to :recipient, class_name: 'User', inverse_of: :received_notifications + belongs_to :unit + belongs_to :project, optional: true + belongs_to :task, optional: true + belongs_to :actor, class_name: 'User', optional: true, inverse_of: :acted_notifications + belongs_to :source, polymorphic: true, optional: true + + validates :kind, inclusion: { in: KINDS } + validates :deduplication_key, presence: true, uniqueness: { scope: :recipient_id } + validate :metadata_is_an_object + + scope :unread, -> { where(read_at: nil) } + scope :recently_read, -> { where(read_at: 30.days.ago..) } + scope :email_pending, -> { unread.where(email_processed_at: nil) } + + before_validation :normalize_metadata + + def self.create_for_task_comment(comment) + kind = kind_for_comment(comment) + return if kind.nil? + + recipients_for_comment(comment).each do |recipient, recipient_task| + next if comment.read_by?(recipient) + + if kind == 'task_status_changed' + resolve_for(recipient: recipient, task: recipient_task, kinds: kind) + elsif kind == 'discuss_expired' + resolve_for(recipient: recipient, task: recipient_task, kinds: 'discuss_warning') + end + + notification = create_event( + recipient: recipient, + unit: recipient_task.unit, + project: recipient_task.project, + task: recipient_task, + actor: comment.user, + kind: kind, + source: comment, + deduplication_key: "task-comment:#{comment.id}:#{kind}", + metadata: metadata_for_comment(comment, recipient_task) + ) + + SendImmediateNotificationJob.perform_async(notification.id) if notification && DISCUSS_KINDS.include?(kind) + end + end + + def self.create_for_overseer(assessment) + latest_assessment = assessment.task.overseer_assessments.order(created_at: :desc, id: :desc).first + assessment_comment = assessment.latest_assessment_comment + return unless assessment == latest_assessment && assessment.failed? && assessment_comment.present? + + student_task_recipients(assessment.task).each do |recipient, recipient_task| + mark_read( + where( + recipient: recipient, + task: recipient_task, + kind: 'overseer_failed' + ).where.not(source: assessment).unread + ) + next if assessment_comment.read_by?(recipient) + + create_event( + recipient: recipient, + unit: recipient_task.unit, + project: recipient_task.project, + task: recipient_task, + actor: assessment.task.project.tutor_for(assessment.task.task_definition), + kind: 'overseer_failed', + source: assessment, + deduplication_key: "overseer-assessment:#{assessment.id}:failed", + metadata: { + email_not_before: ( + assessment.updated_at + OverseerAssessment.student_notification_grace_period + ).iso8601 + } + ) + end + end + + def self.create_pdf_failure(task) + version = task.file_uploaded_at&.to_i || task.updated_at.to_i + + student_task_recipients(task).each do |recipient, recipient_task| + create_event( + recipient: recipient, + unit: recipient_task.unit, + project: recipient_task.project, + task: recipient_task, + actor: task.project.tutor_for(task.task_definition), + kind: 'pdf_generation_failed', + source: task, + deduplication_key: "pdf-generation:#{task.id}:#{version}:failed", + metadata: {} + ) + end + end + + def self.create_for_tutor_note(tutor_note, recipient) + create_event( + recipient: recipient, + unit: tutor_note.unit_role.unit, + project: tutor_note.task&.project, + task: tutor_note.task, + actor: tutor_note.user, + kind: 'tutor_note', + source: tutor_note, + deduplication_key: "tutor-note:#{tutor_note.id}", + metadata: { + unit_role_id: tutor_note.unit_role_id, + tutor_note_id: tutor_note.id + } + ) + end + + def self.create_event(**attributes) + recipient = attributes.fetch(:recipient) + unit = attributes.fetch(:unit) + deduplication_key = attributes.fetch(:deduplication_key) + preference = NotificationPreference.for(recipient, unit) + + notification = find_or_initialize_by( + recipient: recipient, + deduplication_key: deduplication_key + ) + return notification if notification.persisted? + + notification.assign_attributes( + unit: unit, + project: attributes[:project], + task: attributes[:task], + actor: attributes[:actor], + kind: attributes.fetch(:kind), + source: attributes[:source], + metadata: attributes.fetch(:metadata, {}) + ) + notification.save! + notification.update!(email_processed_at: Time.current) if preference.email_frequency == 'off' + notification + rescue ActiveRecord::RecordNotUnique + find_by(recipient: recipient, deduplication_key: deduplication_key) + end + + def self.mark_read(relation, at: Time.current) + # A group must share one exact read timestamp so read history can reconstruct the group. + # rubocop:disable Rails/SkipsModelValidations + relation.update_all( + [ + 'read_at = ?, email_processed_at = COALESCE(email_processed_at, ?), updated_at = ?', + at, + at, + at + ] + ) + # rubocop:enable Rails/SkipsModelValidations + end + + def self.mark_task_read(recipient, task) + mark_read(where(recipient: recipient, task: task).unread) + end + + def self.reopen_for_source(source, recipient) + # Keep email_processed_at unchanged so manually reopening a comment never resends email. + # rubocop:disable Rails/SkipsModelValidations + where(source: source, recipient: recipient).update_all(read_at: nil, updated_at: Time.current) + # rubocop:enable Rails/SkipsModelValidations + end + + def self.resolve_task_kinds(task, kinds) + tasks = related_group_tasks(task) + mark_read(where(task: tasks, kind: Array(kinds)).unread) + end + + def self.resolve_for(recipient:, task:, kinds:) + mark_read(where(recipient: recipient, task: task, kind: Array(kinds)).unread) + end + + def email_ready?(at: Time.current) + email_not_before = metadata['email_not_before'] || metadata[:email_not_before] + return true if email_not_before.blank? + + Time.zone.parse(email_not_before) <= at + rescue ArgumentError, TypeError + true + end + + def self.kind_for_comment(comment) + case comment + when TaskStatusComment + return nil if student_actor?(comment) + + 'task_status_changed' + when DiscussTimeoutComment + comment.content_type == DiscussTimeoutComment.expired ? 'discuss_expired' : 'discuss_warning' + when AssessmentComment + nil + else + return nil if INTERNAL_COMMENT_TYPES.include?(comment.content_type) + + 'feedback_left' + end + end + + def self.metadata_for_comment(comment, recipient_task) + result = {} + result[:status] = comment.task_status.status_key if comment.is_a?(TaskStatusComment) + result[:deadline] = recipient_task.unit.discuss_timeout_expiry_date(recipient_task)&.iso8601 if comment.content_type == DiscussTimeoutComment.warning + result + end + + def self.recipients_for_comment(comment) + if !student_actor?(comment) && comment.task.group_task? && comment.task.group_submission.present? + student_task_recipients(comment.task) + else + [[comment.recipient, comment.task]] + end + end + + def self.student_task_recipients(task) + related_group_tasks(task).filter_map do |recipient_task| + student = recipient_task.project.student + [student, recipient_task] unless student.nil? + end + end + + def self.related_group_tasks(task) + return [task] unless task.group_task? && task.group_submission_id.present? + + Task.where(group_submission_id: task.group_submission_id).includes(project: :user).to_a + end + + def self.student_actor?(comment) + comment.user == comment.project.student || comment.task.role_for(comment.user).in?(%i[student group_member]) + end + + def metadata_is_an_object + errors.add(:metadata, 'must be a JSON object') unless metadata.is_a?(Hash) + end + + def normalize_metadata + self.metadata ||= {} + return unless metadata.is_a?(String) + + parsed = JSON.parse(metadata) + self.metadata = parsed if parsed.is_a?(Hash) + rescue JSON::ParserError + nil + end + + private_class_method :kind_for_comment, :metadata_for_comment, :recipients_for_comment, :student_actor? +end diff --git a/app/models/notification_preference.rb b/app/models/notification_preference.rb new file mode 100644 index 0000000000..e803515dbc --- /dev/null +++ b/app/models/notification_preference.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +class NotificationPreference < ApplicationRecord + FREQUENCIES = %w[off hourly daily weekly].freeze + TIME_FORMAT = /\A(?:[01]\d|2[0-3]):[0-5]\d\z/ + + attribute :email_categories, :json + + belongs_to :user + belongs_to :unit + + validates :email_frequency, inclusion: { in: FREQUENCIES } + validates :email_time, format: { with: TIME_FORMAT } + validates :email_weekday, inclusion: { in: 1..7 } + validates :timezone, presence: true + validate :valid_timezone + validate :valid_email_categories + + before_validation :apply_defaults + before_validation :normalize_email_categories + before_save :refresh_next_digest_at, if: :schedule_changed? + after_commit :process_pending_notifications_when_off, if: :saved_change_to_off? + + scope :due, -> { where.not(email_frequency: 'off').where(next_digest_at: ..Time.current) } + + def self.for(user, unit) + find_or_create_by!(user: user, unit: unit) do |preference| + preference.email_categories = default_categories(user) + preference.timezone = default_timezone(user, unit) + end + end + + def self.default_categories(user) + categories = ['tutor_note'] + categories.push('feedback_left', 'task_status_changed', 'discuss_warning', 'discuss_expired') if user.receive_feedback_notifications + categories.push('overseer_failed', 'pdf_generation_failed') if user.receive_task_notifications + categories.uniq + end + + def self.default_timezone(user, unit) + return Time.zone.name if unit.unit_roles.exists?(user_id: user.id) + + Project.find_by(user: user, unit: unit)&.campus&.timezone.presence || Time.zone.name + end + + def email_enabled_for?(kind) + email_frequency != 'off' && email_categories.include?(kind) + end + + def advance_digest!(from: Time.current) + update!(last_digest_at: from, next_digest_at: next_occurrence(from)) + end + + def next_occurrence(from = Time.current) + return nil if email_frequency == 'off' + + zone = timezone_object + local_from = from.in_time_zone(zone) + return (local_from.beginning_of_hour + 1.hour).utc if email_frequency == 'hourly' + + hour, minute = email_time.split(':').map(&:to_i) + candidate_date = local_from.to_date + + if email_frequency == 'weekly' + days_ahead = (email_weekday - candidate_date.cwday) % 7 + candidate_date += days_ahead.days + end + + candidate = zone.local(candidate_date.year, candidate_date.month, candidate_date.day, hour, minute) + candidate += email_frequency == 'weekly' ? 1.week : 1.day if candidate <= local_from + candidate.utc + end + + def timezone_object + ActiveSupport::TimeZone[timezone] || Time.zone + end + + private + + def apply_defaults + self.email_categories ||= self.class.default_categories(user) + self.email_frequency ||= 'weekly' + self.email_time ||= '09:00' + self.email_weekday ||= 1 + self.timezone ||= self.class.default_timezone(user, unit) + end + + def refresh_next_digest_at + self.next_digest_at = next_occurrence(Time.current) + end + + def schedule_changed? + next_digest_at.nil? || + will_save_change_to_email_frequency? || + will_save_change_to_email_time? || + will_save_change_to_email_weekday? || + will_save_change_to_timezone? + end + + def valid_timezone + errors.add(:timezone, 'must be a valid timezone') if timezone.present? && ActiveSupport::TimeZone[timezone].nil? + end + + def valid_email_categories + unless email_categories.is_a?(Array) + errors.add(:email_categories, 'must be an array') + return + end + + invalid = Array(email_categories) - Notification::KINDS + errors.add(:email_categories, "contains unsupported categories: #{invalid.join(', ')}") if invalid.any? + end + + def normalize_email_categories + return unless email_categories.is_a?(String) + + parsed = JSON.parse(email_categories) + self.email_categories = parsed if parsed.is_a?(Array) + rescue JSON::ParserError + nil + end + + def saved_change_to_off? + email_frequency == 'off' && saved_change_to_email_frequency? + end + + def process_pending_notifications_when_off + now = Time.current + # These are delivery-ledger updates and intentionally bypass callbacks. + # rubocop:disable Rails/SkipsModelValidations + user.received_notifications.where(unit: unit).email_pending.update_all( + email_processed_at: now, + updated_at: now + ) + # rubocop:enable Rails/SkipsModelValidations + end +end diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb index d01a3210ed..0373bbc449 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -6,6 +6,7 @@ class OverseerAssessment < ApplicationRecord has_one :project, through: :task has_many :assessment_comments, as: :commentable, dependent: :destroy has_many :overseer_step_results, dependent: :destroy + has_many :notifications, as: :source, dependent: :destroy validates :status, presence: true validates :task_id, presence: true @@ -15,6 +16,10 @@ class OverseerAssessment < ApplicationRecord validates :submission_history_id, uniqueness: true validate :submission_history_matches_task + after_update_commit do + Notification.create_for_overseer(self) if saved_change_to_status? && failed? + end + enum :status, { pre_queued: 0, passed: 1, failed: 2 } def submission_history_matches_task @@ -43,7 +48,6 @@ def self.student_notification_grace_period AND student_read_receipts.user_id = projects.user_id SQL .where(status: statuses[:failed], student_notified_at: nil) - .where(users: { receive_task_notifications: true }) .where('overseer_assessments.updated_at <= ?', notification_cutoff) .where('student_read_receipts.id IS NULL') .where(<<~SQL.squish) diff --git a/app/models/portfolio_evidence.rb b/app/models/portfolio_evidence.rb index 65d1d481b6..76e64af6e2 100644 --- a/app/models/portfolio_evidence.rb +++ b/app/models/portfolio_evidence.rb @@ -62,6 +62,7 @@ def self.process_new_to_pdf(my_source) if success done[task.project] = [] if done[task.project].nil? done[task.project] << task + Notification.resolve_task_kinds(task, 'pdf_generation_failed') else add_error.call('Failed to convert your submission to pdf.') end @@ -70,16 +71,8 @@ def self.process_new_to_pdf(my_source) end end - errors.each do |project, tasks| - logger.debug "checking email for project #{project.id}" - next unless project.student.receive_task_notifications - - logger.info "emailing task notification to #{project.student.name}" - begin - PortfolioEvidenceMailer.task_pdf_failed(project, tasks).deliver - rescue StandardError => e - logger.error "Failed to send task pdf failed email for project #{project.id}!\n#{e.message}" - end + errors.each_value do |tasks| + tasks.each { |task| Notification.create_pdf_failure(task) } end end diff --git a/app/models/project.rb b/app/models/project.rb index b74cc84add..4d86b5567a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -31,6 +31,7 @@ class Project < ApplicationRecord has_many :comments, through: :tasks has_many :tutorial_enrolments, dependent: :destroy has_many :session_activities, dependent: :destroy + has_many :notifications, dependent: :destroy has_many :staff_notes, dependent: :destroy has_many :engagements, dependent: :destroy, inverse_of: :project diff --git a/app/models/task.rb b/app/models/task.rb index 9a535b5a13..21bddbcb12 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -141,6 +141,8 @@ def specific_permission_hash(role, perm_hash, _other) has_many :tii_submissions, dependent: :destroy has_many :test_attempts, dependent: :destroy has_many :session_activities, dependent: :destroy + has_many :notifications, dependent: :destroy + has_many :source_notifications, as: :source, class_name: 'Notification', dependent: :destroy delegate :unit, to: :project delegate :student, to: :project diff --git a/app/models/tutor_note.rb b/app/models/tutor_note.rb index dbc42645d7..ee7ad0eed4 100644 --- a/app/models/tutor_note.rb +++ b/app/models/tutor_note.rb @@ -3,6 +3,7 @@ class TutorNote < ApplicationRecord belongs_to :user belongs_to :task, optional: true belongs_to :reply_to, class_name: "TutorNote", optional: true + has_many :notifications, as: :source, dependent: :destroy def task_definition_id task&.task_definition&.id diff --git a/app/models/unit.rb b/app/models/unit.rb index a3757c62e3..6b4355af8c 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -181,6 +181,8 @@ def role_for(user) has_many :communication_set_schedules, through: :communication_sets, class_name: 'CommunicationSetSchedule' has_many :unit_content_sites, dependent: :destroy has_many :unit_content_links, dependent: :destroy + has_many :notifications, dependent: :destroy + has_many :notification_preferences, dependent: :destroy has_many :comments, through: :projects has_many :tasks, through: :projects @@ -320,7 +322,6 @@ def warn_discuss_timeout_task(task, actor, teaching_breaks: nil) raise ActiveRecord::Rollback if comment.blank? task.update!(notified_discuss_warning_at: Time.zone.now) - queue_discuss_timeout_email(task, actor, :approaching, expiry_date) created_comment = true end @@ -346,7 +347,6 @@ def expire_discuss_timeout_task(task, actor) raise ActiveRecord::Rollback end - queue_discuss_timeout_email(task, actor, :missed) created_comment = true end @@ -366,13 +366,6 @@ def formatted_discuss_timeout_date(date) "#{result} #{date.year}" end - def queue_discuss_timeout_email(task, actor, type, expiry_date = nil) - return unless send_notifications - return unless task.project.student.receive_feedback_notifications - - SendDiscussTimeoutEmailJob.perform_async(task.id, actor.id, type.to_s, expiry_date&.iso8601) - end - def detailed_name "#{name} #{teaching_period.present? ? teaching_period.detailed_name : start_date.strftime('%Y-%m-%d')}" end @@ -3101,19 +3094,6 @@ def update_task_status_from_csv(user, csv_str, success, _ignored, errors) end end - # send emails... - begin - done.each do |project, tasks| - logger.info "Checking feedback email for project #{project.id}" - if project.student.receive_feedback_notifications - logger.info "Emailing feedback notification to #{project.student.name}" - PortfolioEvidenceMailer.task_feedback_ready(project, tasks).deliver - end - end - rescue => e - logger.error "Failed to send emails from feedback submission. Rescued with error: #{e.message}" - end - true end diff --git a/app/models/user.rb b/app/models/user.rb index 67a50d1877..a393b69af1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -182,6 +182,17 @@ def token_for_text?(a_token, token_type) has_many :chip_usage, dependent: :destroy, inverse_of: :tutor, class_name: 'Feedback::ChipUsage' has_many :marking_sessions, dependent: :destroy + has_many :received_notifications, + class_name: 'Notification', + foreign_key: :recipient_id, + dependent: :destroy, + inverse_of: :recipient + has_many :acted_notifications, + class_name: 'Notification', + foreign_key: :actor_id, + dependent: :nullify, + inverse_of: :actor + has_many :notification_preferences, dependent: :destroy # Model validations/constraints validates :first_name, presence: true diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb new file mode 100644 index 0000000000..ba50e4760d --- /dev/null +++ b/app/services/notification_group_builder.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +class NotificationGroupBuilder + SEVERITY_ORDER = { + 'critical' => 0, + 'warning' => 1, + 'normal' => 2 + }.freeze + + def initialize(notifications) + @notifications = notifications.to_a + end + + def groups + @notifications + .group_by { |notification| grouping_key(notification) } + .values + .map { |items| serialize(items) } + .sort_by { |group| [group[:read] ? 1 : 0, SEVERITY_ORDER.fetch(group[:severity]), -group[:latest_at].to_f] } + end + + private + + def grouping_key(notification) + state_key = notification.read_at ? "read:#{notification.read_at.to_f}" : 'unread' + return "#{state_key}:task:#{notification.task_id}" if notification.task_id.present? + + unit_role_id = notification.metadata['unit_role_id'] || notification.metadata[:unit_role_id] + return "#{state_key}:tutor-notes:#{unit_role_id}" if notification.kind == 'tutor_note' + + "#{state_key}:unit:#{notification.unit_id}:#{notification.kind}" + end + + def serialize(items) + latest = items.max_by(&:created_at) + task = latest.task + counts = items.each_with_object(Hash.new(0)) { |notification, result| result[notification.kind] += 1 } + latest_status = items + .select { |notification| notification.kind == 'task_status_changed' } + .max_by(&:created_at) + &.metadata + &.fetch('status', nil) + tutor_notes = items + .select { |notification| notification.kind == 'tutor_note' } + .sort_by { |notification| [notification.created_at, notification.id] } + + { + key: grouping_key(latest), + notification_ids: items.map(&:id), + tutor_note_notification_ids: tutor_notes.map(&:id), + unit: { + id: latest.unit.id, + code: latest.unit.code, + name: latest.unit.name + }, + task: task_details(task, latest.recipient), + counts: counts, + event_count: items.count, + latest_status: latest_status, + severity: severity_for(items), + read: items.all? { |notification| notification.read_at.present? }, + read_at: items.filter_map(&:read_at).max, + latest_at: latest.created_at, + tutor_note_ids: tutor_notes.filter_map { |notification| notification.metadata['tutor_note_id'] }, + tutor_note_unit_role_id: tutor_notes.first&.metadata&.fetch('unit_role_id', nil), + summary: summary_for(task, latest.recipient, counts, latest_status) + } + end + + def task_details(task, recipient) + return nil if task.nil? + + staff_view = task.project.student != recipient + + { + id: task.id, + project_id: task.project_id, + task_definition_id: task.task_definition_id, + abbreviation: task.task_definition.abbreviation, + name: task.task_definition.name, + staff_view: staff_view, + student_name: staff_view ? task.project.student.name : nil + } + end + + def severity_for(items) + kinds = items.map(&:kind) + return 'critical' if kinds.intersect?(%w[discuss_expired pdf_generation_failed]) + return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed tutor_note]) + + 'normal' + end + + def summary_for(task, recipient, counts, latest_status) + details = [] + details << 'discussion deadline missed' if counts['discuss_expired'].positive? + details << 'discussion deadline approaching' if counts['discuss_warning'].positive? + details << 'submission PDF generation failed' if counts['pdf_generation_failed'].positive? + details << 'automated assessment failed' if counts['overseer_failed'].positive? + details << pluralize(counts['tutor_note'], 'tutor note') if counts['tutor_note'].positive? + details << pluralize(counts['feedback_left'], 'new comment') if counts['feedback_left'].positive? + details << "status changed to #{latest_status.to_s.humanize}" if latest_status.present? + + subject = + if task + task.project.student == recipient ? task.task_definition.abbreviation : "#{task.task_definition.abbreviation} for #{task.project.student.name}" + else + 'Unit notification' + end + "#{subject} — #{details.to_sentence}" + end + + def pluralize(count, noun) + "#{count} #{count == 1 ? noun : noun.pluralize}" + end +end diff --git a/app/sidekiq/accept_overseer_job.rb b/app/sidekiq/accept_overseer_job.rb index 59ddd753fd..b818e2332a 100644 --- a/app/sidekiq/accept_overseer_job.rb +++ b/app/sidekiq/accept_overseer_job.rb @@ -91,6 +91,7 @@ def perform(task_id, _output_path, docker_image_name_tag, submission, assessment if steps_attempted == steps_passed && assessment_pass oa.update!(status: :passed) + Notification.resolve_task_kinds(task, 'overseer_failed') unless success_status.nil? # TODO: have an override status setting for the step? eg. if the task is overdue, let it remain overdue, otherwise use this task status task.update!(task_status: success_status) diff --git a/app/sidekiq/accept_submission_job.rb b/app/sidekiq/accept_submission_job.rb index eaaf9b380e..d0bc6c4fe7 100644 --- a/app/sidekiq/accept_submission_job.rb +++ b/app/sidekiq/accept_submission_job.rb @@ -30,14 +30,7 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission) rescue StandardError => e logger.error e - # Send email to student if task pdf failed - if task.project.student.receive_task_notifications - begin - PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver - rescue StandardError => e - logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{e.message}" - end - end + Notification.create_pdf_failure(task) begin # Notify system admin @@ -60,6 +53,8 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission) return end + Notification.resolve_task_kinds(task, 'pdf_generation_failed') + # Mark this task for moderation tutor_user = task.project.tutor_for(task.task_definition) if tutor_user && !test_submission diff --git a/app/sidekiq/create_pending_overseer_notifications_job.rb b/app/sidekiq/create_pending_overseer_notifications_job.rb new file mode 100644 index 0000000000..e5291ab28c --- /dev/null +++ b/app/sidekiq/create_pending_overseer_notifications_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreatePendingOverseerNotificationsJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['create-pending-overseer-notifications'] }, + on_conflict: :reject, + retry: 1 + + def perform + OverseerAssessment.awaiting_student_failure_notification.find_each do |assessment| + Notification.create_for_overseer(assessment) + assessment.update!(student_notified_at: Time.current) + end + end +end diff --git a/app/sidekiq/notify_tutor_notes_job.rb b/app/sidekiq/notify_tutor_notes_job.rb index 194c6b4f35..971749690f 100644 --- a/app/sidekiq/notify_tutor_notes_job.rb +++ b/app/sidekiq/notify_tutor_notes_job.rb @@ -5,7 +5,7 @@ def perform(tutor_note_id, recipient_user_id) tutor_note = TutorNote.find(tutor_note_id) recipient = User.find(recipient_user_id) - TutorNoteMailer.notify_tutor_note(tutor_note, recipient).deliver + Notification.create_for_tutor_note(tutor_note, recipient) rescue StandardError => e Rails.logger.error("Failed to send tutor note email for TutorNote #{tutor_note_id} to User #{recipient_user_id}: #{e.class} - #{e.message}") end diff --git a/app/sidekiq/poll_notification_digests_job.rb b/app/sidekiq/poll_notification_digests_job.rb new file mode 100644 index 0000000000..54915e0d52 --- /dev/null +++ b/app/sidekiq/poll_notification_digests_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class PollNotificationDigestsJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['poll-notification-digests'] }, + on_conflict: :reject, + retry: 1 + + def perform + NotificationPreference.due.find_each do |preference| + SendNotificationDigestJob.perform_async(preference.id) + end + end +end diff --git a/app/sidekiq/prune_notifications_job.rb b/app/sidekiq/prune_notifications_job.rb new file mode 100644 index 0000000000..5ed818dc6a --- /dev/null +++ b/app/sidekiq/prune_notifications_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class PruneNotificationsJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['prune-notifications'] }, + on_conflict: :reject, + retry: 1 + + def perform + Notification + .where.not(read_at: nil) + .where(read_at: ...90.days.ago) + .delete_all + end +end diff --git a/app/sidekiq/send_immediate_notification_job.rb b/app/sidekiq/send_immediate_notification_job.rb new file mode 100644 index 0000000000..b10bef1b6b --- /dev/null +++ b/app/sidekiq/send_immediate_notification_job.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class SendImmediateNotificationJob + include Sidekiq::Job + + sidekiq_options retry: 5 + + def perform(notification_id) + notification = Notification.find(notification_id) + return if notification.email_processed_at.present? + + preference = NotificationPreference.for(notification.recipient, notification.unit) + unless notification.read_at.nil? && + notification.unit.send_notifications && + preference.email_enabled_for?(notification.kind) + notification.update!(email_processed_at: Time.current) + return + end + + sender = notification.actor || notification.unit.main_convenor_user + if sender.nil? + notification.update!(email_processed_at: Time.current) + return + end + + mail = + if notification.kind == 'discuss_warning' + deadline = notification.metadata['deadline'] + NotificationsMailer.discussion_deadline_approaching( + notification.task, + sender, + deadline.present? ? Date.iso8601(deadline) : notification.unit.discuss_timeout_expiry_date(notification.task) + ) + else + NotificationsMailer.discussion_deadline_missed(notification.task, sender) + end + + mail.deliver_now + notification.update!( + email_processed_at: Time.current, + email_sent_at: Time.current + ) + end +end diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb new file mode 100644 index 0000000000..e026d35dad --- /dev/null +++ b/app/sidekiq/send_notification_digest_job.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class SendNotificationDigestJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { ["notification-digest:#{args.first}"] }, + on_conflict: :reject, + retry: 5 + + def perform(preference_id) + preference = NotificationPreference.find(preference_id) + return if preference.email_frequency == 'off' + + now = Time.current + pending = preference.user + .received_notifications + .where(unit: preference.unit) + .email_pending + .where.not(kind: Notification::DISCUSS_KINDS) + .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) + ready_ids = pending.select { |notification| notification.email_ready?(at: now) }.map(&:id) + ready = Notification.where(id: ready_ids) + + disabled = ready.where.not(kind: preference.email_categories) + # These are immutable delivery-ledger updates and intentionally bypass callbacks. + # rubocop:disable Rails/SkipsModelValidations + disabled.update_all(email_processed_at: now, updated_at: now) + # rubocop:enable Rails/SkipsModelValidations + + enabled = ready + .where(kind: preference.email_categories) + .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) + .to_a + unless preference.unit.send_notifications + # rubocop:disable Rails/SkipsModelValidations + Notification.where(id: enabled.map(&:id)).update_all(email_processed_at: now, updated_at: now) + # rubocop:enable Rails/SkipsModelValidations + preference.advance_digest!(from: now) + return + end + + if enabled.any? + NotificationsMailer.notification_digest(preference, enabled).deliver_now + # rubocop:disable Rails/SkipsModelValidations + Notification.where(id: enabled.map(&:id)).update_all( + email_processed_at: now, + email_sent_at: now, + updated_at: now + ) + # rubocop:enable Rails/SkipsModelValidations + end + + preference.advance_digest!(from: now) + end +end diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb new file mode 100644 index 0000000000..6b54fb481e --- /dev/null +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -0,0 +1,100 @@ + + + + + + +
+

<%= @unit.name %> - Notification Summary

+

<%= @unit.code %>

+ +

Hi <%= @recipient.first_name %>,

+ +

+ You have <%= @notification_count %> new <%= 'change'.pluralize(@notification_count) %> + across <%= @groups.count %> <%= 'notification'.pluralize(@groups.count) %>. +

+ + <% @groups.each do |group| %> +
+ <%= group[:summary] %> + <% if group[:task] %> +

+ + View this task + +

+ <% end %> +
+ <% end %> + +

+ View all notifications and email settings +

+ +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> +

+
+ + + + diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb new file mode 100644 index 0000000000..b01e60889c --- /dev/null +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -0,0 +1,16 @@ +Hi <%= @recipient.first_name %>, + +You have <%= @notification_count %> new <%= 'change'.pluralize(@notification_count) %> across <%= @groups.count %> <%= 'notification'.pluralize(@groups.count) %> in <%= @unit.code %>. + +<% @groups.each do |group| %> +- <%= group[:summary] %> +<% if group[:task] %> + <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> +<% end %> +<% end %> + +View all notifications and email settings: +<%= @notification_url %> + +Cheers, +The <%= @doubtfire_product_name %> Team diff --git a/config/schedule.yml b/config/schedule.yml index adb2a1f282..d591b65aaf 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -28,6 +28,18 @@ notify_discuss_timeout: cron: "every day at 8am" class: "NotifyDiscussTimeoutJob" +poll_notification_digests: + cron: "every 5 minutes" + class: "PollNotificationDigestsJob" + +create_pending_overseer_notifications: + cron: "every 5 minutes" + class: "CreatePendingOverseerNotificationsJob" + +prune_notifications: + cron: "every day at 2am" + class: "PruneNotificationsJob" + # archive_old_units: # cron: "every 6 months" # class: "ArchiveOldUnitsJob" diff --git a/db/migrate/20260729043436_create_notifications_and_notification_preferences.rb b/db/migrate/20260729043436_create_notifications_and_notification_preferences.rb new file mode 100644 index 0000000000..8f2b2ff780 --- /dev/null +++ b/db/migrate/20260729043436_create_notifications_and_notification_preferences.rb @@ -0,0 +1,54 @@ +class CreateNotificationsAndNotificationPreferences < ActiveRecord::Migration[8.0] + def change + create_table :notifications do |t| + t.references :recipient, null: false, foreign_key: { to_table: :users } + t.references :unit, null: false, foreign_key: true + t.references :project, null: true, foreign_key: true + t.references :task, null: true, foreign_key: true + t.references :actor, null: true, foreign_key: { to_table: :users } + + t.string :kind, null: false, limit: 64 + t.string :source_type, null: true, limit: 64 + t.bigint :source_id, null: true + t.string :deduplication_key, null: false, limit: 191 + t.json :metadata, null: false + + t.datetime :read_at + t.datetime :email_processed_at + t.datetime :email_sent_at + + t.timestamps + end + + add_index :notifications, [:source_type, :source_id] + add_index :notifications, [:recipient_id, :deduplication_key], + unique: true, + name: 'index_notifications_on_recipient_and_deduplication_key' + add_index :notifications, [:recipient_id, :read_at, :created_at], + name: 'index_notifications_on_recipient_read_created' + add_index :notifications, [:recipient_id, :unit_id, :email_processed_at], + name: 'index_notifications_for_email_delivery' + add_index :notifications, [:recipient_id, :task_id, :read_at], + name: 'index_notifications_on_recipient_task_read' + + create_table :notification_preferences do |t| + t.references :user, null: false, foreign_key: true + t.references :unit, null: false, foreign_key: true + + t.json :email_categories, null: false + t.string :email_frequency, null: false, default: 'weekly', limit: 16 + t.string :email_time, null: false, default: '09:00', limit: 5 + t.integer :email_weekday, null: false, default: 1 + t.string :timezone, null: false, default: 'UTC' + t.datetime :next_digest_at + t.datetime :last_digest_at + + t.timestamps + end + + add_index :notification_preferences, [:user_id, :unit_id], + unique: true, + name: 'index_notification_preferences_on_user_and_unit' + add_index :notification_preferences, :next_digest_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 90bbe62f82..0b53985eb5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_24_015355) do +ActiveRecord::Schema[8.0].define(version: 2026_07_29_043436) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -342,6 +342,54 @@ t.index ["task_id"], name: "index_moderated_tasks_on_task_id" end + create_table "notification_preferences", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "unit_id", null: false + t.text "email_categories", size: :long, null: false, collation: "utf8mb4_bin" + t.string "email_frequency", limit: 16, default: "weekly", null: false + t.string "email_time", limit: 5, default: "09:00", null: false + t.integer "email_weekday", default: 1, null: false + t.string "timezone", default: "UTC", null: false + t.datetime "next_digest_at" + t.datetime "last_digest_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["next_digest_at"], name: "index_notification_preferences_on_next_digest_at" + t.index ["unit_id"], name: "index_notification_preferences_on_unit_id" + t.index ["user_id", "unit_id"], name: "index_notification_preferences_on_user_and_unit", unique: true + t.index ["user_id"], name: "index_notification_preferences_on_user_id" + t.check_constraint "json_valid(`email_categories`)", name: "email_categories" + end + + create_table "notifications", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "recipient_id", null: false + t.bigint "unit_id", null: false + t.bigint "project_id" + t.bigint "task_id" + t.bigint "actor_id" + t.string "kind", limit: 64, null: false + t.string "source_type", limit: 64 + t.bigint "source_id" + t.string "deduplication_key", limit: 191, null: false + t.text "metadata", size: :long, null: false, collation: "utf8mb4_bin" + t.datetime "read_at" + t.datetime "email_processed_at" + t.datetime "email_sent_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["actor_id"], name: "index_notifications_on_actor_id" + t.index ["project_id"], name: "index_notifications_on_project_id" + t.index ["recipient_id", "deduplication_key"], name: "index_notifications_on_recipient_and_deduplication_key", unique: true + t.index ["recipient_id", "read_at", "created_at"], name: "index_notifications_on_recipient_read_created" + t.index ["recipient_id", "task_id", "read_at"], name: "index_notifications_on_recipient_task_read" + t.index ["recipient_id", "unit_id", "email_processed_at"], name: "index_notifications_for_email_delivery" + t.index ["recipient_id"], name: "index_notifications_on_recipient_id" + t.index ["source_type", "source_id"], name: "index_notifications_on_source_type_and_source_id" + t.index ["task_id"], name: "index_notifications_on_task_id" + t.index ["unit_id"], name: "index_notifications_on_unit_id" + t.check_constraint "json_valid(`metadata`)", name: "metadata" + end + create_table "overflow_task_claim_logs", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id", null: false t.bigint "task_id", null: false @@ -1030,6 +1078,13 @@ add_foreign_key "feedback_chips", "learning_outcomes" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "source_id" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "target_id" + add_foreign_key "notification_preferences", "units" + add_foreign_key "notification_preferences", "users" + add_foreign_key "notifications", "projects" + add_foreign_key "notifications", "tasks" + add_foreign_key "notifications", "units" + add_foreign_key "notifications", "users", column: "actor_id" + add_foreign_key "notifications", "users", column: "recipient_id" add_foreign_key "user_oauth_states", "users" add_foreign_key "user_oauth_tokens", "users" end diff --git a/test/api/comments/status_test.rb b/test/api/comments/status_test.rb index 9c2e729b12..3d744af371 100644 --- a/test/api/comments/status_test.rb +++ b/test/api/comments/status_test.rb @@ -55,13 +55,13 @@ def test_status_comments rff_comment = task.comments.where(task_status_id: TaskStatus.ready_for_feedback.id).first te_comment = task.comments.where(task_status_id: TaskStatus.time_exceeded.id).first - # Task status generated by students is marked read by staff + # Task status generated by students is read by the author but remains unread for staff. assert rff_comment.read_by?(user), 'Error: RFF status comment should be read by the student' - assert rff_comment.read_by?(tutor), 'Error: TE status comment should be read by the tutor' + assert_not rff_comment.read_by?(tutor), 'Error: RFF status comment should remain unread for the tutor' - # Task status comments by staff is not marked read by students + # Task status comments by staff remain unread for students until comments are opened. assert te_comment.read_by?(tutor), 'Error: TE status comment should be read by the tutor' - assert te_comment.read_by?(user), 'Error: TE status comment should be read by the student' + assert_not te_comment.read_by?(user), 'Error: TE status comment should remain unread for the student' td.destroy! end diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb new file mode 100644 index 0000000000..e2060058fd --- /dev/null +++ b/test/api/notifications_api_test.rb @@ -0,0 +1,103 @@ +require 'test_helper' + +class NotificationsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def setup + super + @unit = FactoryBot.create(:unit, task_count: 1) + @project = @unit.active_projects.first + @task = @project.task_for_task_definition(@unit.task_definitions.first) + @student = @project.student + @tutor = @project.tutor_for(@task.task_definition) + @task.add_text_comment(@tutor, 'New feedback') + add_auth_header_for(user: @student) + end + + def test_get_returns_only_current_users_grouped_notifications + other_user = FactoryBot.create(:user) + FactoryBot.create(:notification, recipient: other_user, unit: @unit) + + get '/api/notifications' + + assert_equal 200, last_response.status + assert_equal 1, last_response_body['groups'].count + assert_equal @task.id, last_response_body.dig('groups', 0, 'task', 'id') + assert_equal 1, last_response_body['unread_count'] + end + + def test_unread_count_counts_a_task_group_instead_of_each_event + @task.add_text_comment(@tutor, 'More feedback') + @task.add_status_comment(@tutor, TaskStatus.fix_and_resubmit) + + get '/api/notifications/unread_count' + + assert_equal 200, last_response.status + assert_equal 1, last_response_body['count'] + end + + def test_get_filters_groups_by_category_and_search + get '/api/notifications', + state: 'unread', + kinds: ['feedback_left'], + query: @unit.code + + assert_equal 200, last_response.status + assert_equal 1, last_response_body['groups'].count + assert_equal({ 'feedback_left' => 1 }, last_response_body.dig('groups', 0, 'counts')) + end + + def test_mark_read_cannot_update_another_users_notification + own_notification = Notification.find_by!(recipient: @student) + other_notification = FactoryBot.create(:notification, unit: @unit) + + put_json '/api/notifications/read', + notification_ids: [own_notification.id, other_notification.id] + + assert_equal 200, last_response.status + assert_not_nil own_notification.reload.read_at + assert_nil other_notification.reload.read_at + end + + def test_mark_all_read_can_be_scoped_to_a_unit + own_notification = Notification.find_by!(recipient: @student) + other_unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + other_notification = FactoryBot.create(:notification, recipient: @student, unit: other_unit) + + put_json '/api/notifications/read_all', unit_id: @unit.id + + assert_equal 200, last_response.status + assert_not_nil own_notification.reload.read_at + assert_nil other_notification.reload.read_at + end + + def test_updating_preferences_requires_unit_access + inaccessible_unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + + put_json "/api/notification_preferences/#{inaccessible_unit.id}", + email_categories: ['feedback_left'], + email_frequency: 'daily', + email_time: '10:30', + email_weekday: 1, + timezone: 'UTC' + + assert_equal 403, last_response.status + end + + def test_updating_preferences_validates_timezone + put_json "/api/notification_preferences/#{@unit.id}", + email_categories: ['feedback_left'], + email_frequency: 'daily', + email_time: '10:30', + email_weekday: 1, + timezone: 'Not/A-Timezone' + + assert_equal 400, last_response.status + end +end diff --git a/test/factories/notifications.rb b/test/factories/notifications.rb new file mode 100644 index 0000000000..67174bb5db --- /dev/null +++ b/test/factories/notifications.rb @@ -0,0 +1,19 @@ +FactoryBot.define do + factory :notification do + recipient { create(:user) } + unit { create(:unit, with_students: false, task_count: 0) } + kind { 'feedback_left' } + sequence(:deduplication_key) { |number| "factory-notification-#{number}" } + metadata { {} } + end + + factory :notification_preference do + user + unit { create(:unit, with_students: false, task_count: 0) } + email_categories { Notification::KINDS } + email_frequency { 'weekly' } + email_time { '09:00' } + email_weekday { 1 } + timezone { 'UTC' } + end +end diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 86285d28f0..f6107ce634 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -110,22 +110,22 @@ def test_discuss_timeout_notifications_send_emails task.update!(moved_to_discuss_at: 8.days.ago) assert_equal 1, unit.notify_discuss_timeouts! - assert_equal 1, SendDiscussTimeoutEmailJob.jobs.count + assert_equal 1, SendImmediateNotificationJob.jobs.count - approaching_job = SendDiscussTimeoutEmailJob.jobs.shift + approaching_job = SendImmediateNotificationJob.jobs.shift assert_emails 1 do - SendDiscussTimeoutEmailJob.new.perform(*approaching_job['args']) + SendImmediateNotificationJob.new.perform(*approaching_job['args']) end assert_includes ActionMailer::Base.deliveries.last.subject, 'Discussion deadline approaching' task.update!(moved_to_discuss_at: 15.days.ago) assert_equal 1, unit.notify_discuss_timeouts! - assert_equal 1, SendDiscussTimeoutEmailJob.jobs.count + assert_equal 1, SendImmediateNotificationJob.jobs.count - missed_job = SendDiscussTimeoutEmailJob.jobs.shift + missed_job = SendImmediateNotificationJob.jobs.shift assert_emails 1 do - SendDiscussTimeoutEmailJob.new.perform(*missed_job['args']) + SendImmediateNotificationJob.new.perform(*missed_job['args']) end assert_includes ActionMailer::Base.deliveries.last.subject, 'Discussion deadline missed' end diff --git a/test/models/notification_preference_test.rb b/test/models/notification_preference_test.rb new file mode 100644 index 0000000000..5458892626 --- /dev/null +++ b/test/models/notification_preference_test.rb @@ -0,0 +1,111 @@ +require 'test_helper' + +class NotificationPreferenceTest < ActiveSupport::TestCase + def test_defaults_to_monday_morning_weekly_delivery + user = FactoryBot.create(:user) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + + preference = NotificationPreference.for(user, unit) + + assert_equal 'weekly', preference.email_frequency + assert_equal '09:00', preference.email_time + assert_equal 1, preference.email_weekday + assert_includes preference.email_categories, 'tutor_note' + end + + def test_default_categories_preserve_legacy_opt_outs_only_when_first_created + user = FactoryBot.create( + :user, + receive_feedback_notifications: false, + receive_task_notifications: false + ) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + + preference = NotificationPreference.for(user, unit) + user.update!( + receive_feedback_notifications: true, + receive_task_notifications: true + ) + + assert_equal ['tutor_note'], preference.reload.email_categories + end + + def test_student_default_timezone_uses_their_project_campus + unit = FactoryBot.create(:unit, task_count: 0) + project = unit.active_projects.first + project.campus.update!(timezone: 'Australia/Perth') + + preference = NotificationPreference.for(project.student, unit) + + assert_equal 'Australia/Perth', preference.timezone + end + + def test_next_occurrence_uses_the_selected_timezone + preference = FactoryBot.build( + :notification_preference, + email_frequency: 'daily', + email_time: '09:00', + timezone: 'Australia/Melbourne' + ) + from = Time.utc(2026, 7, 29, 1, 0, 0) + + next_occurrence = preference.next_occurrence(from) + + assert_equal 9, next_occurrence.in_time_zone('Australia/Melbourne').hour + assert_operator next_occurrence, :>, from + end + + def test_daily_delivery_keeps_local_time_across_daylight_saving_transition + preference = FactoryBot.build( + :notification_preference, + email_frequency: 'daily', + email_time: '09:00', + timezone: 'Australia/Melbourne' + ) + from = Time.utc(2026, 10, 3, 13, 30, 0) + + next_occurrence = preference.next_occurrence(from).in_time_zone('Australia/Melbourne') + + assert_equal Date.new(2026, 10, 4), next_occurrence.to_date + assert_equal 9, next_occurrence.hour + assert_equal '+11:00', next_occurrence.strftime('%:z') + end + + def test_switching_delivery_off_processes_existing_pending_notifications + preference = FactoryBot.create(:notification_preference) + notification = FactoryBot.create( + :notification, + recipient: preference.user, + unit: preference.unit + ) + + preference.update!(email_frequency: 'off') + + assert_not_nil notification.reload.email_processed_at + assert_nil preference.reload.next_digest_at + end + + def test_invalid_categories_are_rejected + preference = FactoryBot.build(:notification_preference, email_categories: ['unknown']) + + assert_not preference.valid? + assert_includes preference.errors[:email_categories].join, 'unknown' + end + + def test_email_categories_must_be_an_array + preference = FactoryBot.build(:notification_preference, email_categories: 'feedback_left') + + assert_not preference.valid? + assert_includes preference.errors[:email_categories], 'must be an array' + end + + def test_email_categories_normalize_a_text_backed_json_value + preference = FactoryBot.build( + :notification_preference, + email_categories: %w[feedback_left tutor_note].to_json + ) + + assert preference.valid? + assert_equal %w[feedback_left tutor_note], preference.email_categories + end +end diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb new file mode 100644 index 0000000000..14cd68f703 --- /dev/null +++ b/test/models/notification_test.rb @@ -0,0 +1,222 @@ +require 'test_helper' + +class NotificationTest < ActiveSupport::TestCase + def setup + super + @unit = FactoryBot.create(:unit, task_count: 1) + @project = @unit.active_projects.first + @task = @project.task_for_task_definition(@unit.task_definitions.first) + @student = @project.student + @tutor = @project.tutor_for(@task.task_definition) + end + + def test_tutor_feedback_creates_an_unread_student_notification + comment = @task.add_text_comment(@tutor, 'Please revise this section') + + notification = Notification.find_by(source: comment, recipient: @student) + + assert_not_nil notification + assert_equal 'feedback_left', notification.kind + assert_equal @task, notification.task + assert_nil notification.read_at + end + + def test_student_feedback_notifies_staff_but_student_status_does_not + comment = @task.add_text_comment(@student, 'Could you clarify this feedback?') + @task.add_status_comment(@student, TaskStatus.ready_for_feedback) + + assert Notification.exists?(source: comment, recipient: @tutor, kind: 'feedback_left') + assert_not Notification.exists?(recipient: @tutor, kind: 'task_status_changed') + end + + def test_staff_group_feedback_fans_out_to_each_students_corresponding_task + unit = FactoryBot.create( + :unit, + task_count: 1, + student_count: 2, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + group_sets: 1, + group_tasks: [{ idx: 0, gs: 0 }], + groups: [{ gs: 0, students: 2 }] + ) + group = unit.groups.first + tasks = group.projects.map { |project| project.task_for_task_definition(unit.task_definitions.first) } + comment = tasks.first.add_text_comment(tasks.first.project.tutor_for(tasks.first.task_definition), 'Group feedback') + group_member_status = tasks.first.add_status_comment( + tasks.second.project.student, + TaskStatus.ready_for_feedback + ) + + notifications = Notification.where(source: comment).order(:recipient_id) + + assert_equal group.projects.map(&:student).sort_by(&:id), notifications.map(&:recipient).sort_by(&:id) + assert_equal tasks.map(&:id).sort, notifications.map(&:task_id).sort + assert_not Notification.exists?(source: group_member_status) + end + + def test_only_latest_staff_status_notification_remains_unread + first_comment = @task.add_status_comment(@tutor, TaskStatus.fix_and_resubmit) + second_comment = @task.add_status_comment(@tutor, TaskStatus.complete) + + first_notification = Notification.find_by!(source: first_comment, recipient: @student) + second_notification = Notification.find_by!(source: second_comment, recipient: @student) + + assert_not_nil first_notification.read_at + assert_nil second_notification.read_at + assert_equal 'complete', second_notification.metadata['status'] + assert_equal 1, Notification.where(recipient: @student, task: @task, kind: 'task_status_changed').unread.count + end + + def test_group_builder_merges_mixed_task_activity + 3.times { |number| @task.add_text_comment(@tutor, "Feedback #{number}") } + @task.add_status_comment(@tutor, TaskStatus.fix_and_resubmit) + + groups = NotificationGroupBuilder.new(Notification.where(recipient: @student).unread).groups + + assert_equal 1, groups.count + assert_equal 3, groups.first[:counts]['feedback_left'] + assert_equal 'fix_and_resubmit', groups.first[:latest_status] + assert_includes groups.first[:summary], '3 new comments' + assert_includes groups.first[:summary], 'Fix and resubmit' + end + + def test_discuss_expiry_supersedes_warning_without_hiding_feedback + @task.add_text_comment(@tutor, 'Please review this before your discussion') + warning = @task.add_discuss_timeout_comment( + @tutor, + DiscussTimeoutComment.warning, + 'Your discussion deadline is approaching' + ) + expiry = @task.add_discuss_timeout_comment( + @tutor, + DiscussTimeoutComment.expired, + 'Your discussion deadline has passed' + ) + + warning_notification = Notification.find_by!(source: warning, recipient: @student) + expiry_notification = Notification.find_by!(source: expiry, recipient: @student) + group = NotificationGroupBuilder.new(Notification.where(recipient: @student).unread).groups.first + + assert_not_nil warning_notification.read_at + assert_nil expiry_notification.read_at + assert_equal 'critical', group[:severity] + assert_equal 1, group[:counts]['discuss_expired'] + assert_equal 1, group[:counts]['feedback_left'] + assert_includes group[:summary], 'discussion deadline missed' + end + + def test_task_tutor_note_and_student_feedback_share_a_group_with_two_actions + @task.add_text_comment(@student, 'Can you check this change?') + unit_role = @unit.unit_role_for(@tutor) + tutor_note = unit_role.add_tutor_note(@unit.main_convenor_user, 'Please follow up', @task.id) + Notification.create_for_tutor_note(tutor_note, @tutor) + + group = NotificationGroupBuilder.new(Notification.where(recipient: @tutor).unread).groups.first + + assert_equal 1, group[:counts]['feedback_left'] + assert_equal 1, group[:counts]['tutor_note'] + assert_equal [tutor_note.id], group[:tutor_note_ids] + assert_equal [Notification.find_by!(source: tutor_note).id], group[:tutor_note_notification_ids] + assert group.dig(:task, :staff_view) + assert_equal @student.name, group.dig(:task, :student_name) + end + + def test_duplicate_source_event_is_deduplicated_per_recipient + comment = @task.add_text_comment(@tutor, 'Only notify once') + attributes = { + recipient: @student, + unit: @unit, + project: @project, + task: @task, + actor: @tutor, + kind: 'feedback_left', + source: comment, + deduplication_key: 'same-event', + metadata: {} + } + + first = Notification.create_event(**attributes) + second = Notification.create_event(**attributes) + + assert_equal first, second + assert_equal 1, Notification.where(recipient: @student, deduplication_key: 'same-event').count + end + + def test_metadata_normalizes_a_text_backed_json_value + notification = FactoryBot.build(:notification, metadata: { source: 'comment' }.to_json) + + assert notification.valid? + assert_equal({ 'source' => 'comment' }, notification.metadata) + end + + def test_overseer_failure_is_created_immediately_but_email_is_held_for_the_grace_period + submission_history = FactoryBot.create(:submission_history, task: @task) + assessment = FactoryBot.create( + :overseer_assessment, + task: @task, + submission_history: submission_history, + status: :pre_queued + ) + assessment.add_assessment_comment('Automated tests failed') + + assessment.update!(status: :failed) + + grace_period = OverseerAssessment.student_notification_grace_period + notification = Notification.find_by!( + source: assessment, + recipient: @student, + kind: 'overseer_failed' + ) + assert notification.email_ready?(at: Time.current + grace_period + 1.minute) + assert_not notification.email_ready?(at: Time.current + grace_period - 1.minute) + end + + def test_destroying_a_source_removes_its_notification + comment = @task.add_text_comment(@tutor, 'Temporary feedback') + notification = Notification.find_by!(source: comment, recipient: @student) + + comment.destroy! + + assert_not Notification.exists?(notification.id) + end + + def test_unread_groups_sort_before_read_history_even_when_read_group_is_urgent + read_notification = FactoryBot.create( + :notification, + recipient: @student, + unit: @unit, + kind: 'discuss_expired', + read_at: Time.current + ) + unread_notification = FactoryBot.create( + :notification, + recipient: @student, + unit: @unit, + kind: 'feedback_left' + ) + + groups = NotificationGroupBuilder.new([read_notification, unread_notification]).groups + + assert_equal unread_notification.id, groups.first[:notification_ids].first + assert_not groups.first[:read] + end + + def test_marking_task_read_processes_email_and_marking_source_unread_reopens_in_app_only + comment = @task.add_text_comment(@tutor, 'Read me') + notification = Notification.find_by!(source: comment, recipient: @student) + + Notification.mark_task_read(@student, @task) + notification.reload + + assert_not_nil notification.read_at + assert_not_nil notification.email_processed_at + + Notification.reopen_for_source(comment, @student) + notification.reload + + assert_nil notification.read_at + assert_not_nil notification.email_processed_at + end +end diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb new file mode 100644 index 0000000000..148ecfd915 --- /dev/null +++ b/test/sidekiq/notification_jobs_test.rb @@ -0,0 +1,98 @@ +require 'test_helper' + +class NotificationJobsTest < ActiveSupport::TestCase + def test_digest_sends_only_enabled_unread_events + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + user = FactoryBot.create(:user) + preference = FactoryBot.create( + :notification_preference, + user: user, + unit: unit, + email_categories: ['feedback_left'], + next_digest_at: 1.minute.ago + ) + enabled = FactoryBot.create(:notification, recipient: user, unit: unit, kind: 'feedback_left') + disabled = FactoryBot.create(:notification, recipient: user, unit: unit, kind: 'pdf_generation_failed') + + assert_emails 1 do + SendNotificationDigestJob.new.perform(preference.id) + end + + digest_html = ActionMailer::Base.deliveries.last.html_part.body.to_s + assert_equal 1, digest_html.scan(' - - -
-

<%= @doubtfire_product_name %> Notification

-

Hi <%= @student.first_name %>,

-

- I have checked your tasks and provided some feedback. Login and check the status <%= @hasComments ? 'and comments' : ''%> of the following tasks: -

-

-

- Cheers,
- The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> -

-
- - - diff --git a/app/views/portfolio_evidence_mailer/task_feedback_ready.text.erb b/app/views/portfolio_evidence_mailer/task_feedback_ready.text.erb deleted file mode 100644 index d40a8c7ce5..0000000000 --- a/app/views/portfolio_evidence_mailer/task_feedback_ready.text.erb +++ /dev/null @@ -1,16 +0,0 @@ -Hi <%= @student.first_name %>, - -I have checked your tasks and provided some feedback. Login and check the status<%= @has_comments ? ' and comments' : ''%> of the following tasks: - -<% @tasks.each do |task| %> - * <%= task.task_definition.abbreviation %> - <%=task.task_definition.name%> <%= task.is_last_comment_by?(@tutor) ? ' - with comments' : ''%> -<% end %> - -Cheers, -The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> - ---- - -Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. - -Generated with <%= @doubtfire_product_name %> From dab18a63b536a5df3f47921631279ebf787a619d Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:32:59 +1000 Subject: [PATCH 06/57] chore: remove deprecated discuss timeout email --- app/sidekiq/send_discuss_timeout_email_job.rb | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 app/sidekiq/send_discuss_timeout_email_job.rb diff --git a/app/sidekiq/send_discuss_timeout_email_job.rb b/app/sidekiq/send_discuss_timeout_email_job.rb deleted file mode 100644 index 76349e7835..0000000000 --- a/app/sidekiq/send_discuss_timeout_email_job.rb +++ /dev/null @@ -1,24 +0,0 @@ -class SendDiscussTimeoutEmailJob - include Sidekiq::Job - - sidekiq_options retry: 5 - - def perform(task_id, sender_id, notification_type, expiry_date = nil) - task = Task.find_by(id: task_id) - sender = User.find_by(id: sender_id) - return if task.blank? || sender.blank? - return unless task.unit.send_notifications - return unless task.project.student.receive_feedback_notifications - - mail = case notification_type - when 'approaching' - NotificationsMailer.discussion_deadline_approaching(task, sender, Date.iso8601(expiry_date)) - when 'missed' - NotificationsMailer.discussion_deadline_missed(task, sender) - else - raise ArgumentError, "Unknown discussion deadline notification type: #{notification_type}" - end - - mail.deliver_now - end -end From d1cd629c6eb282c036b1a7b2dbb10b9f01c597fa Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:33:09 +1000 Subject: [PATCH 07/57] chore: remove conflict comment types --- app/models/notification.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/models/notification.rb b/app/models/notification.rb index cd7d127f42..2f28eb099a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -12,7 +12,6 @@ class Notification < ApplicationRecord ].freeze DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze - INTERNAL_COMMENT_TYPES = %w[assessment checked_in discussed_in_class plan].freeze attribute :metadata, :json, default: -> { {} } @@ -244,7 +243,11 @@ def self.kind_for_comment(comment) when AssessmentComment nil else - return nil if INTERNAL_COMMENT_TYPES.include?(comment.content_type) + # Automated bookkeeping comments - plan changes, check-ins, discussed-in-class, + # feedback review requests - are attention_audience :none and raise no + # notification. Status comments are :none as well, but they do notify and are + # handled by the branch above. + return nil if comment.attention_none? 'feedback_left' end From 806270b5e11260549943272460978b02b956c217 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:37:18 +1000 Subject: [PATCH 08/57] refactor: use new notification settings --- app/api/notifications_api.rb | 114 +++++++++------ app/api/tutor_notes_api.rb | 13 +- app/mailers/notifications_mailer.rb | 22 ++- .../concerns/has_notification_channels.rb | 35 +++++ app/models/notification.rb | 33 +++-- app/models/notification_preference.rb | 134 +----------------- app/models/notification_setting.rb | 108 ++++++++++++++ app/models/user.rb | 1 + app/services/notification_group_builder.rb | 11 +- app/sidekiq/notify_tutor_notes_job.rb | 4 +- app/sidekiq/poll_notification_digests_job.rb | 4 +- .../send_immediate_notification_job.rb | 4 +- app/sidekiq/send_notification_digest_job.rb | 76 +++++----- .../notification_digest.html.erb | 34 +++-- .../notification_digest.text.erb | 8 +- ...5_create_notifications_and_preferences.rb} | 36 +++-- db/schema.rb | 30 ++-- test/api/notifications_api_test.rb | 99 ++++++++++--- test/factories/notifications.rb | 15 +- test/models/notification_preference_test.rb | 111 --------------- test/models/notification_setting_test.rb | 112 +++++++++++++++ test/models/notification_test.rb | 18 +-- test/sidekiq/notification_jobs_test.rb | 86 +++++++---- 23 files changed, 633 insertions(+), 475 deletions(-) create mode 100644 app/models/concerns/has_notification_channels.rb create mode 100644 app/models/notification_setting.rb rename db/migrate/{20260825000645_create_notifications_and_notification_preferences.rb => 20260825000645_create_notifications_and_preferences.rb} (59%) delete mode 100644 test/models/notification_preference_test.rb create mode 100644 test/models/notification_setting_test.rb diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index e191437998..65d786573f 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -10,17 +10,28 @@ class NotificationsApi < Grape::API end helpers do + def notification_settings + @notification_settings ||= NotificationSetting.for(current_user) + end + def notification_scope current_user .received_notifications .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) end + # Kinds the user has switched off in the app stay in the ledger for the + # digest, so they are dropped here rather than never recorded. + def shown_in_app(notifications) + notifications.select { |notification| notification_settings.shows_in_app?(notification.unit_id, notification.kind) } + end + def unread_group_count - current_user.received_notifications.unread.pluck(:task_id, :unit_id, :kind, :metadata).map do |task_id, unit_id, kind, metadata| + rows = current_user.received_notifications.unread.pluck(:task_id, :unit_id, :kind, :metadata) + rows.select { |_task_id, unit_id, kind, _metadata| notification_settings.shows_in_app?(unit_id, kind) }.map do |task_id, unit_id, kind, metadata| if task_id.present? "task:#{task_id}" - elsif kind == 'tutor_note' + elsif Notification::MODERATION_KINDS.include?(kind) "tutor-notes:#{unit_id}:#{metadata['unit_role_id']}" else "unit:#{unit_id}:#{kind}" @@ -28,30 +39,46 @@ def unread_group_count end.uniq.count end + def serialize_settings(settings) + { + id: settings.id, + channels: settings.channels, + digest_frequency: settings.digest_frequency, + digest_time: settings.digest_time, + digest_weekday: settings.digest_weekday, + weekly_summary: settings.weekly_summary, + next_digest_at: settings.next_digest_at, + last_digest_at: settings.last_digest_at, + units: current_user.notification_preferences.order(:unit_id).map { |preference| serialize_preference(preference) } + } + end + def serialize_preference(preference) { - id: preference.id, - unit: { - id: preference.unit.id, - code: preference.unit.code, - name: preference.unit.name - }, - email_categories: preference.email_categories, - email_frequency: preference.email_frequency, - email_time: preference.email_time, - email_weekday: preference.email_weekday, - timezone: preference.timezone, - next_digest_at: preference.next_digest_at, - last_digest_at: preference.last_digest_at + unit_id: preference.unit_id, + muted: preference.muted, + channels: preference.channels } end + # The units sent are the whole set that departs from the defaults, so any unit + # missing from it has been reset and no longer needs a row. + def replace_unit_preferences(units) + accessible = accessible_unit_ids + wanted = Array(units).select { |unit| accessible.include?(unit[:unit_id]) } + + current_user.notification_preferences.where.not(unit_id: wanted.map { |unit| unit[:unit_id] }).destroy_all + wanted.each do |unit| + preference = current_user.notification_preferences.find_or_initialize_by(unit_id: unit[:unit_id]) + preference.update!(muted: unit[:muted], channels: unit[:channels]) + end + end + def accessible_unit_ids project_units = current_user.projects.where(enrolled: true).select(:unit_id) role_units = current_user.unit_roles.select(:unit_id) - preference_units = current_user.notification_preferences.select(:unit_id) - Unit.where(id: project_units).or(Unit.where(id: role_units)).or(Unit.where(id: preference_units)).pluck(:id) + Unit.where(id: project_units).or(Unit.where(id: role_units)).pluck(:id) end end @@ -79,7 +106,7 @@ def accessible_unit_ids scope.where('notifications.read_at IS NULL OR notifications.read_at >= ?', 30.days.ago) end - groups = NotificationGroupBuilder.new(scope).groups + groups = NotificationGroupBuilder.new(shown_in_app(scope)).groups if params[:query].present? query = params[:query].downcase groups.select! do |group| @@ -135,36 +162,33 @@ def accessible_unit_ids { count: count } end - desc 'Get per-unit notification email preferences' - get '/notification_preferences' do - preferences = Unit.where(id: accessible_unit_ids).order(:code).map do |unit| - NotificationPreference.for(current_user, unit) - end - - preferences.map { |preference| serialize_preference(preference) } + desc 'Get the notification settings for the current user' + get '/notification_settings' do + serialize_settings(NotificationSetting.for(current_user)) end - desc 'Update notification email preferences for a unit' + desc 'Update the notification settings for the current user' params do - requires :email_categories, type: Array[String], values: Notification::KINDS - requires :email_frequency, type: String, values: NotificationPreference::FREQUENCIES - requires :email_time, type: String - requires :email_weekday, type: Integer - requires :timezone, type: String + optional :channels, type: Hash + optional :digest_frequency, type: String, values: NotificationSetting::FREQUENCIES + optional :digest_time, type: String + optional :digest_weekday, type: Integer + optional :weekly_summary, type: Boolean + optional :units, type: Array do + requires :unit_id, type: Integer + requires :muted, type: Boolean + optional :channels, type: Hash + end end - put '/notification_preferences/:unit_id' do - unit_id = params[:unit_id].to_i - error!({ error: 'You do not have access to notification settings for this unit' }, 403) unless accessible_unit_ids.include?(unit_id) - - preference = NotificationPreference.for(current_user, Unit.find(unit_id)) - preference.update!( - email_categories: params[:email_categories], - email_frequency: params[:email_frequency], - email_time: params[:email_time], - email_weekday: params[:email_weekday], - timezone: params[:timezone] - ) - - serialize_preference(preference) + put '/notification_settings' do + settings = NotificationSetting.for(current_user) + changes = declared(params, include_missing: false) + + NotificationSetting.transaction do + settings.update!(changes.except(:units)) + replace_unit_preferences(changes[:units]) if changes.key?(:units) + end + + serialize_settings(settings) end end diff --git a/app/api/tutor_notes_api.rb b/app/api/tutor_notes_api.rb index 83c99945ce..5c197d258a 100644 --- a/app/api/tutor_notes_api.rb +++ b/app/api/tutor_notes_api.rb @@ -113,18 +113,21 @@ def can_access_tutor_notes?(unit, current_user, unit_role) reply_target = original_staff_note && unit.unit_role_for(original_staff_note.user) - notify_unit_role = + notify_unit_role, notification_kind = if reply_target && original_staff_note.user != current_user - reply_target # tutor is responding to a reply -> notify original user that tutor is replying to + # tutor is responding to a reply -> notify original user that tutor is replying to + [reply_target, 'moderation_note_reply'] elsif current_unit_role == unit_role - unit_role.mentor # tutor is writing on their own notes -> notify the mentor + # tutor is writing on their own notes -> notify the mentor + [unit_role.mentor, 'moderation_note_from_mentee'] else - unit_role # anyone else wrote about this tutor, whether its their mentor or another convenor -> notify tutor + # anyone else wrote about this tutor, whether its their mentor or another convenor -> notify tutor + [unit_role, 'moderation_note_added'] end if result.present? && notify_unit_role.present? && notify_unit_role.user_id != current_user.id begin - NotifyTutorNotesJob.perform_async(result.id, notify_unit_role.user.id) + NotifyTutorNotesJob.perform_async(result.id, notify_unit_role.user.id, notification_kind) rescue StandardError => e Rails.logger.error("Failed to send tutor note email for TutorNote #{result.id}: #{e.class} - #{e.message}") end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index f06b1b40ce..34baa32b00 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -83,24 +83,20 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) mail(to: email_with_name, from: tutor_email, subject: subject) end - def notification_digest(preference, notifications) - return nil if preference.nil? || notifications.blank? + def notification_digest(recipient, notifications) + return nil if recipient.nil? || notifications.blank? add_general - @recipient = preference.user - @unit = preference.unit - @groups = NotificationGroupBuilder.new(notifications).groups + @recipient = recipient @notification_count = notifications.count @notification_url = "#{@doubtfire_host}/notifications" - @sender = @unit.main_convenor_user - return nil if @sender.nil? + @units = notifications.group_by(&:unit).sort_by { |unit, _| unit.code }.map do |unit, for_unit| + { unit: unit, groups: NotificationGroupBuilder.new(for_unit).groups } + end + @group_count = @units.sum { |section| section[:groups].count } - subject = "#{@unit.code}: #{@notification_count} new #{'change'.pluralize(@notification_count)} across #{@groups.count} #{'notification'.pluralize(@groups.count)}" - mail( - to: %("#{@recipient.name}" <#{@recipient.email}>), - from: %("#{@sender.name}" <#{@sender.email}>), - subject: subject - ) + subject = "#{@notification_count} new #{'change'.pluralize(@notification_count)} across #{@group_count} #{'notification'.pluralize(@group_count)}" + mail(to: %("#{@recipient.name}" <#{@recipient.email}>), subject: subject) end def discussion_deadline_approaching(task, sender, expiry_date) diff --git a/app/models/concerns/has_notification_channels.rb b/app/models/concerns/has_notification_channels.rb new file mode 100644 index 0000000000..bf194977d2 --- /dev/null +++ b/app/models/concerns/has_notification_channels.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Shared handling of the `channels` column, which maps a notification kind to the +# channels it is delivered on: { 'new_task_comment' => ['in_app', 'email'] }. +module HasNotificationChannels + extend ActiveSupport::Concern + + included do + attribute :channels, :json + + validate :channels_are_known + end + + # The channels enabled for a kind, or none when the kind is switched off. + def channels_for(kind) + Array(channels && channels[kind.to_s]) + end + + private + + def channels_are_known + return if channels.nil? + + unless channels.is_a?(Hash) + errors.add(:channels, 'must be an object') + return + end + + unknown_kinds = channels.keys - Notification::KINDS + errors.add(:channels, "includes unknown kinds: #{unknown_kinds.join(', ')}") if unknown_kinds.any? + + unknown_channels = channels.values.flatten.uniq - Notification::CHANNELS + errors.add(:channels, "includes unknown channels: #{unknown_channels.join(', ')}") if unknown_channels.any? + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 2f28eb099a..6d3235043f 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -2,16 +2,21 @@ class Notification < ApplicationRecord KINDS = %w[ - feedback_left + new_task_comment task_status_changed overseer_failed pdf_generation_failed discuss_warning discuss_expired - tutor_note + moderation_note_added + moderation_note_reply + moderation_note_from_mentee ].freeze + CHANNELS = %w[in_app email push].freeze + DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze + MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze attribute :metadata, :json, default: -> { {} } @@ -30,15 +35,6 @@ class Notification < ApplicationRecord scope :recently_read, -> { where(read_at: 30.days.ago..) } scope :email_pending, -> { unread.where(email_processed_at: nil) } - # Excludes notifications addressed to a student about a project they have - # withdrawn from. Staff notifications about that project are unaffected. - scope :for_enrolled_recipients, lambda { - left_outer_joins(:project) - .where( - 'projects.id IS NULL OR projects.enrolled = TRUE OR projects.user_id <> notifications.recipient_id' - ) - } - before_validation :normalize_metadata def self.create_for_task_comment(comment) @@ -124,14 +120,14 @@ def self.create_pdf_failure(task) end end - def self.create_for_tutor_note(tutor_note, recipient) + def self.create_for_tutor_note(tutor_note, recipient, kind) create_event( recipient: recipient, unit: tutor_note.unit_role.unit, project: tutor_note.task&.project, task: tutor_note.task, actor: tutor_note.user, - kind: 'tutor_note', + kind: kind, source: tutor_note, deduplication_key: "tutor-note:#{tutor_note.id}", metadata: { @@ -144,10 +140,13 @@ def self.create_for_tutor_note(tutor_note, recipient) def self.create_event(**attributes) recipient = attributes.fetch(:recipient) unit = attributes.fetch(:unit) + kind = attributes.fetch(:kind) deduplication_key = attributes.fetch(:deduplication_key) return if withdrawn_student?(attributes[:project], recipient) - preference = NotificationPreference.for(recipient, unit) + settings = NotificationSetting.for(recipient) + channels = settings.channels_for_unit(unit.id, kind) + return if channels.empty? notification = find_or_initialize_by( recipient: recipient, @@ -160,12 +159,12 @@ def self.create_event(**attributes) project: attributes[:project], task: attributes[:task], actor: attributes[:actor], - kind: attributes.fetch(:kind), + kind: kind, source: attributes[:source], metadata: attributes.fetch(:metadata, {}) ) notification.save! - notification.update!(email_processed_at: Time.current) if preference.email_frequency == 'off' + notification.update!(email_processed_at: Time.current) unless channels.include?('email') notification rescue ActiveRecord::RecordNotUnique find_by(recipient: recipient, deduplication_key: deduplication_key) @@ -249,7 +248,7 @@ def self.kind_for_comment(comment) # handled by the branch above. return nil if comment.attention_none? - 'feedback_left' + 'new_task_comment' end end diff --git a/app/models/notification_preference.rb b/app/models/notification_preference.rb index e803515dbc..1a5c178839 100644 --- a/app/models/notification_preference.rb +++ b/app/models/notification_preference.rb @@ -1,137 +1,15 @@ # frozen_string_literal: true +# One unit's departure from a user's notification settings. A unit with no +# preference follows the user's defaults; a preference with null channels has +# only been muted, and still follows them. class NotificationPreference < ApplicationRecord - FREQUENCIES = %w[off hourly daily weekly].freeze - TIME_FORMAT = /\A(?:[01]\d|2[0-3]):[0-5]\d\z/ - - attribute :email_categories, :json + include HasNotificationChannels belongs_to :user belongs_to :unit - validates :email_frequency, inclusion: { in: FREQUENCIES } - validates :email_time, format: { with: TIME_FORMAT } - validates :email_weekday, inclusion: { in: 1..7 } - validates :timezone, presence: true - validate :valid_timezone - validate :valid_email_categories - - before_validation :apply_defaults - before_validation :normalize_email_categories - before_save :refresh_next_digest_at, if: :schedule_changed? - after_commit :process_pending_notifications_when_off, if: :saved_change_to_off? - - scope :due, -> { where.not(email_frequency: 'off').where(next_digest_at: ..Time.current) } - - def self.for(user, unit) - find_or_create_by!(user: user, unit: unit) do |preference| - preference.email_categories = default_categories(user) - preference.timezone = default_timezone(user, unit) - end - end - - def self.default_categories(user) - categories = ['tutor_note'] - categories.push('feedback_left', 'task_status_changed', 'discuss_warning', 'discuss_expired') if user.receive_feedback_notifications - categories.push('overseer_failed', 'pdf_generation_failed') if user.receive_task_notifications - categories.uniq - end - - def self.default_timezone(user, unit) - return Time.zone.name if unit.unit_roles.exists?(user_id: user.id) - - Project.find_by(user: user, unit: unit)&.campus&.timezone.presence || Time.zone.name - end - - def email_enabled_for?(kind) - email_frequency != 'off' && email_categories.include?(kind) - end - - def advance_digest!(from: Time.current) - update!(last_digest_at: from, next_digest_at: next_occurrence(from)) - end - - def next_occurrence(from = Time.current) - return nil if email_frequency == 'off' - - zone = timezone_object - local_from = from.in_time_zone(zone) - return (local_from.beginning_of_hour + 1.hour).utc if email_frequency == 'hourly' - - hour, minute = email_time.split(':').map(&:to_i) - candidate_date = local_from.to_date - - if email_frequency == 'weekly' - days_ahead = (email_weekday - candidate_date.cwday) % 7 - candidate_date += days_ahead.days - end - - candidate = zone.local(candidate_date.year, candidate_date.month, candidate_date.day, hour, minute) - candidate += email_frequency == 'weekly' ? 1.week : 1.day if candidate <= local_from - candidate.utc - end - - def timezone_object - ActiveSupport::TimeZone[timezone] || Time.zone - end - - private - - def apply_defaults - self.email_categories ||= self.class.default_categories(user) - self.email_frequency ||= 'weekly' - self.email_time ||= '09:00' - self.email_weekday ||= 1 - self.timezone ||= self.class.default_timezone(user, unit) - end - - def refresh_next_digest_at - self.next_digest_at = next_occurrence(Time.current) - end - - def schedule_changed? - next_digest_at.nil? || - will_save_change_to_email_frequency? || - will_save_change_to_email_time? || - will_save_change_to_email_weekday? || - will_save_change_to_timezone? - end - - def valid_timezone - errors.add(:timezone, 'must be a valid timezone') if timezone.present? && ActiveSupport::TimeZone[timezone].nil? - end - - def valid_email_categories - unless email_categories.is_a?(Array) - errors.add(:email_categories, 'must be an array') - return - end - - invalid = Array(email_categories) - Notification::KINDS - errors.add(:email_categories, "contains unsupported categories: #{invalid.join(', ')}") if invalid.any? - end - - def normalize_email_categories - return unless email_categories.is_a?(String) - - parsed = JSON.parse(email_categories) - self.email_categories = parsed if parsed.is_a?(Array) - rescue JSON::ParserError - nil - end - - def saved_change_to_off? - email_frequency == 'off' && saved_change_to_email_frequency? - end - - def process_pending_notifications_when_off - now = Time.current - # These are delivery-ledger updates and intentionally bypass callbacks. - # rubocop:disable Rails/SkipsModelValidations - user.received_notifications.where(unit: unit).email_pending.update_all( - email_processed_at: now, - updated_at: now - ) - # rubocop:enable Rails/SkipsModelValidations + def customised? + channels.present? end end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb new file mode 100644 index 0000000000..8a8f9af6a7 --- /dev/null +++ b/app/models/notification_setting.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# A user's notification settings: the one digest schedule they choose, and the +# channel defaults every unit follows until it is customised. +class NotificationSetting < ApplicationRecord + include HasNotificationChannels + + FREQUENCIES = %w[off hourly daily weekly].freeze + TIME_FORMAT = /\A(?:[01]\d|2[0-3]):[0-5]\d\z/ + + belongs_to :user + + validates :digest_frequency, inclusion: { in: FREQUENCIES } + validates :digest_time, format: { with: TIME_FORMAT } + validates :digest_weekday, inclusion: { in: 1..7 } + + before_validation :apply_defaults + before_save :refresh_next_digest_at, if: :schedule_changed? + after_commit :process_pending_notifications, if: :saved_change_to_digest_off? + + scope :due, -> { where.not(digest_frequency: 'off').where(next_digest_at: ..Time.current) } + + def self.for(user) + find_or_create_by!(user: user) + rescue ActiveRecord::RecordNotUnique + find_by!(user: user) + end + + def self.default_channels + Notification::KINDS.index_with { %w[in_app email] } + end + + # The channels a unit delivers a kind on, honouring its preference when it has + # one. A muted unit delivers nothing. + def channels_for_unit(unit_id, kind) + preference = preference_for(unit_id) + return [] if preference&.muted + + preference&.customised? ? preference.channels_for(kind) : channels_for(kind) + end + + def delivers?(unit, kind, channel) + channels_for_unit(unit&.id, kind).include?(channel.to_s) + end + + # Notifications are always recorded so the digest has something to send, so the + # in-app list has to filter them out rather than rely on them never existing. + def shows_in_app?(unit_id, kind) + channels_for_unit(unit_id, kind).include?('in_app') + end + + def advance_digest!(from: Time.current) + update!(last_digest_at: from, next_digest_at: next_occurrence(from)) + end + + def next_occurrence(from = Time.current) + return nil if digest_frequency == 'off' + return from.beginning_of_hour + 1.hour if digest_frequency == 'hourly' + + hour, minute = digest_time.split(':').map(&:to_i) + date = from.to_date + date += (digest_weekday - date.cwday) % 7 if digest_frequency == 'weekly' + + candidate = Time.zone.local(date.year, date.month, date.day, hour, minute) + candidate += digest_frequency == 'weekly' ? 1.week : 1.day if candidate <= from + candidate + end + + private + + def preference_for(unit_id) + return nil if unit_id.nil? + + @preferences_by_unit ||= user.notification_preferences.index_by(&:unit_id) + @preferences_by_unit[unit_id] + end + + def apply_defaults + self.channels = self.class.default_channels if channels.nil? + end + + def refresh_next_digest_at + self.next_digest_at = next_occurrence(Time.current) + end + + def schedule_changed? + next_digest_at.nil? || + will_save_change_to_digest_frequency? || + will_save_change_to_digest_time? || + will_save_change_to_digest_weekday? + end + + def saved_change_to_digest_off? + digest_frequency == 'off' && saved_change_to_digest_frequency? + end + + # Nothing is waiting for a digest that will never run. Alerts are unaffected - + # they never went through the digest in the first place. + def process_pending_notifications + now = Time.current + # rubocop:disable Rails/SkipsModelValidations + user.received_notifications + .email_pending + .where.not(kind: Notification::DISCUSS_KINDS) + .update_all(email_processed_at: now, updated_at: now) + # rubocop:enable Rails/SkipsModelValidations + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 27fbe09078..b574e24733 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -194,6 +194,7 @@ def token_for_text?(a_token, token_type) dependent: :nullify, inverse_of: :actor has_many :notification_preferences, dependent: :destroy + has_one :notification_setting, dependent: :destroy # Model validations/constraints validates :first_name, presence: true, allowed_characters: { type: :first_name } diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index ba50e4760d..edb87ab86c 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -26,7 +26,7 @@ def grouping_key(notification) return "#{state_key}:task:#{notification.task_id}" if notification.task_id.present? unit_role_id = notification.metadata['unit_role_id'] || notification.metadata[:unit_role_id] - return "#{state_key}:tutor-notes:#{unit_role_id}" if notification.kind == 'tutor_note' + return "#{state_key}:tutor-notes:#{unit_role_id}" if Notification::MODERATION_KINDS.include?(notification.kind) "#{state_key}:unit:#{notification.unit_id}:#{notification.kind}" end @@ -41,7 +41,7 @@ def serialize(items) &.metadata &.fetch('status', nil) tutor_notes = items - .select { |notification| notification.kind == 'tutor_note' } + .select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } .sort_by { |notification| [notification.created_at, notification.id] } { @@ -86,7 +86,7 @@ def task_details(task, recipient) def severity_for(items) kinds = items.map(&:kind) return 'critical' if kinds.intersect?(%w[discuss_expired pdf_generation_failed]) - return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed tutor_note]) + return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed] + Notification::MODERATION_KINDS) 'normal' end @@ -97,8 +97,9 @@ def summary_for(task, recipient, counts, latest_status) details << 'discussion deadline approaching' if counts['discuss_warning'].positive? details << 'submission PDF generation failed' if counts['pdf_generation_failed'].positive? details << 'automated assessment failed' if counts['overseer_failed'].positive? - details << pluralize(counts['tutor_note'], 'tutor note') if counts['tutor_note'].positive? - details << pluralize(counts['feedback_left'], 'new comment') if counts['feedback_left'].positive? + moderation_notes = Notification::MODERATION_KINDS.sum { |kind| counts[kind] } + details << pluralize(moderation_notes, 'moderation note') if moderation_notes.positive? + details << pluralize(counts['new_task_comment'], 'new comment') if counts['new_task_comment'].positive? details << "status changed to #{latest_status.to_s.humanize}" if latest_status.present? subject = diff --git a/app/sidekiq/notify_tutor_notes_job.rb b/app/sidekiq/notify_tutor_notes_job.rb index 971749690f..aabb8dcc71 100644 --- a/app/sidekiq/notify_tutor_notes_job.rb +++ b/app/sidekiq/notify_tutor_notes_job.rb @@ -1,11 +1,11 @@ class NotifyTutorNotesJob include Sidekiq::Job - def perform(tutor_note_id, recipient_user_id) + def perform(tutor_note_id, recipient_user_id, kind) tutor_note = TutorNote.find(tutor_note_id) recipient = User.find(recipient_user_id) - Notification.create_for_tutor_note(tutor_note, recipient) + Notification.create_for_tutor_note(tutor_note, recipient, kind) rescue StandardError => e Rails.logger.error("Failed to send tutor note email for TutorNote #{tutor_note_id} to User #{recipient_user_id}: #{e.class} - #{e.message}") end diff --git a/app/sidekiq/poll_notification_digests_job.rb b/app/sidekiq/poll_notification_digests_job.rb index 54915e0d52..cc4e458624 100644 --- a/app/sidekiq/poll_notification_digests_job.rb +++ b/app/sidekiq/poll_notification_digests_job.rb @@ -9,8 +9,8 @@ class PollNotificationDigestsJob retry: 1 def perform - NotificationPreference.due.find_each do |preference| - SendNotificationDigestJob.perform_async(preference.id) + NotificationSetting.due.find_each do |setting| + SendNotificationDigestJob.perform_async(setting.id) end end end diff --git a/app/sidekiq/send_immediate_notification_job.rb b/app/sidekiq/send_immediate_notification_job.rb index aed90cb686..f5326846f5 100644 --- a/app/sidekiq/send_immediate_notification_job.rb +++ b/app/sidekiq/send_immediate_notification_job.rb @@ -9,11 +9,11 @@ def perform(notification_id) notification = Notification.find(notification_id) return if notification.email_processed_at.present? - preference = NotificationPreference.for(notification.recipient, notification.unit) + settings = NotificationSetting.for(notification.recipient) unless notification.read_at.nil? && notification.unit.send_notifications && !notification.recipient_withdrawn? && - preference.email_enabled_for?(notification.kind) + settings.delivers?(notification.unit, notification.kind, :email) notification.update!(email_processed_at: Time.current) return end diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index 4c28283c89..bee9a37fa5 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -8,51 +8,45 @@ class SendNotificationDigestJob on_conflict: :reject, retry: 5 - def perform(preference_id) - preference = NotificationPreference.find(preference_id) - return if preference.email_frequency == 'off' + def perform(setting_id) + setting = NotificationSetting.find(setting_id) + return if setting.digest_frequency == 'off' now = Time.current - pending = preference.user - .received_notifications - .where(unit: preference.unit) - .email_pending - .where.not(kind: Notification::DISCUSS_KINDS) - .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) - ready_ids = pending.select { |notification| notification.email_ready?(at: now) }.map(&:id) - ready = Notification.where(id: ready_ids) - - deliverable_ids = ready.for_enrolled_recipients.where(kind: preference.email_categories).pluck(:id) - - # Categories the user has turned off, plus anything about a project they have withdrawn from. - # These are immutable delivery-ledger updates and intentionally bypass callbacks. - # rubocop:disable Rails/SkipsModelValidations - ready.where.not(id: deliverable_ids).update_all(email_processed_at: now, updated_at: now) - # rubocop:enable Rails/SkipsModelValidations - - enabled = Notification - .where(id: deliverable_ids) - .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) - .to_a - unless preference.unit.send_notifications - # rubocop:disable Rails/SkipsModelValidations - Notification.where(id: enabled.map(&:id)).update_all(email_processed_at: now, updated_at: now) - # rubocop:enable Rails/SkipsModelValidations - preference.advance_digest!(from: now) - return + ready = setting.user + .received_notifications + .email_pending + .where.not(kind: Notification::DISCUSS_KINDS) + .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) + .select { |notification| notification.email_ready?(at: now) } + deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification) } + + mark_processed(skipped, now) + if deliverable.any? + NotificationsMailer.notification_digest(setting.user, deliverable).deliver_now + mark_processed(deliverable, now, sent: true) end - if enabled.any? - NotificationsMailer.notification_digest(preference, enabled).deliver_now - # rubocop:disable Rails/SkipsModelValidations - Notification.where(id: enabled.map(&:id)).update_all( - email_processed_at: now, - email_sent_at: now, - updated_at: now - ) - # rubocop:enable Rails/SkipsModelValidations - end + setting.advance_digest!(from: now) + end + + private - preference.advance_digest!(from: now) + def deliverable?(setting, notification) + notification.unit.send_notifications && + !notification.recipient_withdrawn? && + setting.delivers?(notification.unit, notification.kind, :email) + end + + # The delivery ledger is immutable once written, so these intentionally bypass callbacks. + def mark_processed(notifications, at, sent: false) + return if notifications.empty? + + attributes = { email_processed_at: at, updated_at: at } + attributes[:email_sent_at] = at if sent + + # rubocop:disable Rails/SkipsModelValidations + Notification.where(id: notifications.map(&:id)).update_all(attributes) + # rubocop:enable Rails/SkipsModelValidations end end diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index 6b54fb481e..a7ce08aa27 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -60,27 +60,31 @@
-

<%= @unit.name %> - Notification Summary

-

<%= @unit.code %>

+

Notification Summary

Hi <%= @recipient.first_name %>,

You have <%= @notification_count %> new <%= 'change'.pluralize(@notification_count) %> - across <%= @groups.count %> <%= 'notification'.pluralize(@groups.count) %>. + across <%= @group_count %> <%= 'notification'.pluralize(@group_count) %>.

- <% @groups.each do |group| %> -
- <%= group[:summary] %> - <% if group[:task] %> -

- - View this task - -

- <% end %> -
+ <% @units.each do |section| %> +

<%= section[:unit].code %>

+

<%= section[:unit].name %>

+ + <% section[:groups].each do |group| %> +
+ <%= group[:summary] %> + <% if group[:task] %> +

+ + View this task + +

+ <% end %> +
+ <% end %> <% end %>

@@ -89,7 +93,7 @@

Cheers,
- The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> + The <%= @doubtfire_product_name %> Team

diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index b01e60889c..2915844fda 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -1,13 +1,15 @@ Hi <%= @recipient.first_name %>, -You have <%= @notification_count %> new <%= 'change'.pluralize(@notification_count) %> across <%= @groups.count %> <%= 'notification'.pluralize(@groups.count) %> in <%= @unit.code %>. - -<% @groups.each do |group| %> +You have <%= @notification_count %> new <%= 'change'.pluralize(@notification_count) %> across <%= @group_count %> <%= 'notification'.pluralize(@group_count) %>. +<% @units.each do |section| %> +<%= section[:unit].code %> - <%= section[:unit].name %> +<% section[:groups].each do |group| %> - <%= group[:summary] %> <% if group[:task] %> <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> <% end %> <% end %> +<% end %> View all notifications and email settings: <%= @notification_url %> diff --git a/db/migrate/20260825000645_create_notifications_and_notification_preferences.rb b/db/migrate/20260825000645_create_notifications_and_preferences.rb similarity index 59% rename from db/migrate/20260825000645_create_notifications_and_notification_preferences.rb rename to db/migrate/20260825000645_create_notifications_and_preferences.rb index c8a62aa081..cd96455885 100644 --- a/db/migrate/20260825000645_create_notifications_and_notification_preferences.rb +++ b/db/migrate/20260825000645_create_notifications_and_preferences.rb @@ -1,7 +1,7 @@ -class CreateNotificationsAndNotificationPreferences < ActiveRecord::Migration[8.0] +class CreateNotificationsAndPreferences < ActiveRecord::Migration[8.0] def change create_table :notifications do |t| - t.references :recipient, null: false + t.references :recipient, null: false, index: false t.references :unit, null: false t.references :project, null: true t.references :task, null: true @@ -31,24 +31,38 @@ def change add_index :notifications, [:recipient_id, :task_id, :read_at], name: 'index_notifications_on_recipient_task_read' - create_table :notification_preferences do |t| - t.references :user, null: false - t.references :unit, null: false + # One row per user: the single digest schedule, and the channel defaults that + # every unit follows until it is given its own notification_preference. + create_table :notification_settings do |t| + t.references :user, null: false, index: { unique: true } - t.json :email_categories, null: false - t.string :email_frequency, null: false, default: 'weekly', limit: 16 - t.string :email_time, null: false, default: '09:00', limit: 5 - t.integer :email_weekday, null: false, default: 1 - t.string :timezone, null: false, default: 'UTC' + t.json :channels, null: false + t.string :digest_frequency, null: false, default: 'weekly', limit: 16 + t.string :digest_time, null: false, default: '07:00', limit: 5 + t.integer :digest_weekday, null: false, default: 1 + t.boolean :weekly_summary, null: false, default: true t.datetime :next_digest_at t.datetime :last_digest_at t.timestamps end + add_index :notification_settings, :next_digest_at + + # One row per unit the user has changed. Channels are null while the unit + # still follows the user's defaults and has only been muted. + create_table :notification_preferences do |t| + t.references :user, null: false, index: false + t.references :unit, null: false + + t.boolean :muted, null: false, default: false + t.json :channels, null: true + + t.timestamps + end + add_index :notification_preferences, [:user_id, :unit_id], unique: true, name: 'index_notification_preferences_on_user_and_unit' - add_index :notification_preferences, :next_digest_at end end diff --git a/db/schema.rb b/db/schema.rb index 9a0c622c2d..68989b11da 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -375,20 +375,29 @@ create_table "notification_preferences", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "user_id", null: false t.bigint "unit_id", null: false - t.text "email_categories", size: :long, null: false, collation: "utf8mb4_bin" - t.string "email_frequency", limit: 16, default: "weekly", null: false - t.string "email_time", limit: 5, default: "09:00", null: false - t.integer "email_weekday", default: 1, null: false - t.string "timezone", default: "UTC", null: false - t.datetime "next_digest_at" - t.datetime "last_digest_at" + t.boolean "muted", default: false, null: false + t.text "channels", size: :long, collation: "utf8mb4_bin" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["next_digest_at"], name: "index_notification_preferences_on_next_digest_at" t.index ["unit_id"], name: "index_notification_preferences_on_unit_id" t.index ["user_id", "unit_id"], name: "index_notification_preferences_on_user_and_unit", unique: true - t.index ["user_id"], name: "index_notification_preferences_on_user_id" - t.check_constraint "json_valid(`email_categories`)", name: "email_categories" + t.check_constraint "json_valid(`channels`)", name: "channels" + end + + create_table "notification_settings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.text "channels", size: :long, null: false, collation: "utf8mb4_bin" + t.string "digest_frequency", limit: 16, default: "weekly", null: false + t.string "digest_time", limit: 5, default: "07:00", null: false + t.integer "digest_weekday", default: 1, null: false + t.boolean "weekly_summary", default: true, null: false + t.datetime "next_digest_at" + t.datetime "last_digest_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["next_digest_at"], name: "index_notification_settings_on_next_digest_at" + t.index ["user_id"], name: "index_notification_settings_on_user_id", unique: true + t.check_constraint "json_valid(`channels`)", name: "channels" end create_table "notifications", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| @@ -413,7 +422,6 @@ t.index ["recipient_id", "read_at", "created_at"], name: "index_notifications_on_recipient_read_created" t.index ["recipient_id", "task_id", "read_at"], name: "index_notifications_on_recipient_task_read" t.index ["recipient_id", "unit_id", "email_processed_at"], name: "index_notifications_for_email_delivery" - t.index ["recipient_id"], name: "index_notifications_on_recipient_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source_type_and_source_id" t.index ["task_id"], name: "index_notifications_on_task_id" t.index ["unit_id"], name: "index_notifications_on_unit_id" diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index e2060058fd..a0e023b406 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -45,12 +45,12 @@ def test_unread_count_counts_a_task_group_instead_of_each_event def test_get_filters_groups_by_category_and_search get '/api/notifications', state: 'unread', - kinds: ['feedback_left'], + kinds: ['new_task_comment'], query: @unit.code assert_equal 200, last_response.status assert_equal 1, last_response_body['groups'].count - assert_equal({ 'feedback_left' => 1 }, last_response_body.dig('groups', 0, 'counts')) + assert_equal({ 'new_task_comment' => 1 }, last_response_body.dig('groups', 0, 'counts')) end def test_mark_read_cannot_update_another_users_notification @@ -77,27 +77,90 @@ def test_mark_all_read_can_be_scoped_to_a_unit assert_nil other_notification.reload.read_at end - def test_updating_preferences_requires_unit_access - inaccessible_unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + def test_a_kind_switched_off_in_app_is_hidden_but_still_emailed + settings = NotificationSetting.for(@student) + settings.update!(channels: settings.channels.merge('new_task_comment' => ['email'])) - put_json "/api/notification_preferences/#{inaccessible_unit.id}", - email_categories: ['feedback_left'], - email_frequency: 'daily', - email_time: '10:30', - email_weekday: 1, - timezone: 'UTC' + get_json '/api/notifications' - assert_equal 403, last_response.status + assert_equal 200, last_response.status + assert_empty last_response_body['groups'] + assert_equal 0, last_response_body['unread_count'] + + # The event is still on the ledger, waiting for the digest to pick it up. + assert_equal 1, @student.received_notifications.email_pending.where(kind: 'new_task_comment').count end - def test_updating_preferences_validates_timezone - put_json "/api/notification_preferences/#{@unit.id}", - email_categories: ['feedback_left'], - email_frequency: 'daily', - email_time: '10:30', - email_weekday: 1, - timezone: 'Not/A-Timezone' + def test_settings_start_from_the_defaults + get_json '/api/notification_settings' + + assert_equal 200, last_response.status + assert_equal 'weekly', last_response_body['digest_frequency'] + assert_equal %w[in_app email], last_response_body.dig('channels', 'new_task_comment') + assert_empty last_response_body['units'] + end + + def test_updating_settings_stores_the_schedule_and_the_units_that_differ + put_json '/api/notification_settings', + channels: { new_task_comment: ['in_app'] }, + digest_frequency: 'daily', + digest_time: '10:30', + digest_weekday: 1, + weekly_summary: false, + units: [{ unit_id: @unit.id, muted: true }] + + assert_equal 200, last_response.status + assert_equal 'daily', last_response_body['digest_frequency'] + assert_equal ['in_app'], last_response_body.dig('channels', 'new_task_comment') + assert_equal [{ 'unit_id' => @unit.id, 'muted' => true, 'channels' => nil }], last_response_body['units'] + end + + def test_updating_settings_drops_units_that_no_longer_differ + NotificationPreference.create!(user: @student, unit: @unit, muted: true) + + put_json '/api/notification_settings', + channels: { new_task_comment: ['in_app'] }, + digest_frequency: 'daily', + digest_time: '10:30', + digest_weekday: 1, + weekly_summary: true, + units: [] + + assert_equal 200, last_response.status + assert_empty @student.notification_preferences.reload + end + + def test_updating_settings_ignores_units_the_user_cannot_access + inaccessible = FactoryBot.create(:unit, with_students: false, task_count: 0) + + put_json '/api/notification_settings', + channels: { new_task_comment: ['in_app'] }, + digest_frequency: 'daily', + digest_time: '10:30', + digest_weekday: 1, + weekly_summary: true, + units: [{ unit_id: inaccessible.id, muted: true }] + + assert_equal 200, last_response.status + assert_empty last_response_body['units'] + end + + def test_updating_settings_rejects_an_unknown_frequency + put_json '/api/notification_settings', digest_frequency: 'fortnightly' assert_equal 400, last_response.status end + + def test_updating_settings_leaves_out_what_was_not_sent + put_json '/api/notification_settings', digest_frequency: 'daily' + NotificationPreference.create!(user: @student, unit: @unit, muted: true) + + # The client only sends what changed, so an absent key must not clear anything. + put_json '/api/notification_settings', digest_time: '06:00' + + assert_equal 200, last_response.status + assert_equal 'daily', last_response_body['digest_frequency'] + assert_equal '06:00', last_response_body['digest_time'] + assert_equal 1, @student.notification_preferences.reload.count + end end diff --git a/test/factories/notifications.rb b/test/factories/notifications.rb index 67174bb5db..3c12f002c2 100644 --- a/test/factories/notifications.rb +++ b/test/factories/notifications.rb @@ -2,18 +2,21 @@ factory :notification do recipient { create(:user) } unit { create(:unit, with_students: false, task_count: 0) } - kind { 'feedback_left' } + kind { 'new_task_comment' } sequence(:deduplication_key) { |number| "factory-notification-#{number}" } metadata { {} } end + factory :notification_setting do + user + digest_frequency { 'weekly' } + digest_time { '07:00' } + digest_weekday { 1 } + end + factory :notification_preference do user unit { create(:unit, with_students: false, task_count: 0) } - email_categories { Notification::KINDS } - email_frequency { 'weekly' } - email_time { '09:00' } - email_weekday { 1 } - timezone { 'UTC' } + muted { false } end end diff --git a/test/models/notification_preference_test.rb b/test/models/notification_preference_test.rb deleted file mode 100644 index 5458892626..0000000000 --- a/test/models/notification_preference_test.rb +++ /dev/null @@ -1,111 +0,0 @@ -require 'test_helper' - -class NotificationPreferenceTest < ActiveSupport::TestCase - def test_defaults_to_monday_morning_weekly_delivery - user = FactoryBot.create(:user) - unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - - preference = NotificationPreference.for(user, unit) - - assert_equal 'weekly', preference.email_frequency - assert_equal '09:00', preference.email_time - assert_equal 1, preference.email_weekday - assert_includes preference.email_categories, 'tutor_note' - end - - def test_default_categories_preserve_legacy_opt_outs_only_when_first_created - user = FactoryBot.create( - :user, - receive_feedback_notifications: false, - receive_task_notifications: false - ) - unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - - preference = NotificationPreference.for(user, unit) - user.update!( - receive_feedback_notifications: true, - receive_task_notifications: true - ) - - assert_equal ['tutor_note'], preference.reload.email_categories - end - - def test_student_default_timezone_uses_their_project_campus - unit = FactoryBot.create(:unit, task_count: 0) - project = unit.active_projects.first - project.campus.update!(timezone: 'Australia/Perth') - - preference = NotificationPreference.for(project.student, unit) - - assert_equal 'Australia/Perth', preference.timezone - end - - def test_next_occurrence_uses_the_selected_timezone - preference = FactoryBot.build( - :notification_preference, - email_frequency: 'daily', - email_time: '09:00', - timezone: 'Australia/Melbourne' - ) - from = Time.utc(2026, 7, 29, 1, 0, 0) - - next_occurrence = preference.next_occurrence(from) - - assert_equal 9, next_occurrence.in_time_zone('Australia/Melbourne').hour - assert_operator next_occurrence, :>, from - end - - def test_daily_delivery_keeps_local_time_across_daylight_saving_transition - preference = FactoryBot.build( - :notification_preference, - email_frequency: 'daily', - email_time: '09:00', - timezone: 'Australia/Melbourne' - ) - from = Time.utc(2026, 10, 3, 13, 30, 0) - - next_occurrence = preference.next_occurrence(from).in_time_zone('Australia/Melbourne') - - assert_equal Date.new(2026, 10, 4), next_occurrence.to_date - assert_equal 9, next_occurrence.hour - assert_equal '+11:00', next_occurrence.strftime('%:z') - end - - def test_switching_delivery_off_processes_existing_pending_notifications - preference = FactoryBot.create(:notification_preference) - notification = FactoryBot.create( - :notification, - recipient: preference.user, - unit: preference.unit - ) - - preference.update!(email_frequency: 'off') - - assert_not_nil notification.reload.email_processed_at - assert_nil preference.reload.next_digest_at - end - - def test_invalid_categories_are_rejected - preference = FactoryBot.build(:notification_preference, email_categories: ['unknown']) - - assert_not preference.valid? - assert_includes preference.errors[:email_categories].join, 'unknown' - end - - def test_email_categories_must_be_an_array - preference = FactoryBot.build(:notification_preference, email_categories: 'feedback_left') - - assert_not preference.valid? - assert_includes preference.errors[:email_categories], 'must be an array' - end - - def test_email_categories_normalize_a_text_backed_json_value - preference = FactoryBot.build( - :notification_preference, - email_categories: %w[feedback_left tutor_note].to_json - ) - - assert preference.valid? - assert_equal %w[feedback_left tutor_note], preference.email_categories - end -end diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb new file mode 100644 index 0000000000..28832e847c --- /dev/null +++ b/test/models/notification_setting_test.rb @@ -0,0 +1,112 @@ +require 'test_helper' + +class NotificationSettingTest < ActiveSupport::TestCase + def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push + settings = NotificationSetting.for(FactoryBot.create(:user)) + + assert_equal 'weekly', settings.digest_frequency + assert_equal '07:00', settings.digest_time + assert_equal 1, settings.digest_weekday + assert settings.weekly_summary + assert_equal %w[in_app email], settings.channels['new_task_comment'] + assert_equal Notification::KINDS.sort, settings.channels.keys.sort + end + + def test_a_unit_without_a_preference_follows_the_defaults + settings = NotificationSetting.for(FactoryBot.create(:user)) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + + assert settings.delivers?(unit, 'new_task_comment', :email) + assert_not settings.delivers?(unit, 'new_task_comment', :push) + end + + def test_a_customised_unit_uses_its_own_channels + settings = NotificationSetting.for(FactoryBot.create(:user)) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + FactoryBot.create( + :notification_preference, + user: settings.user, + unit: unit, + channels: { 'new_task_comment' => ['push'] } + ) + + assert_not settings.delivers?(unit, 'new_task_comment', :email) + assert settings.delivers?(unit, 'new_task_comment', :push) + end + + def test_a_muted_unit_delivers_nothing + settings = NotificationSetting.for(FactoryBot.create(:user)) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + FactoryBot.create(:notification_preference, user: settings.user, unit: unit, muted: true) + + assert_empty settings.channels_for_unit(unit, 'new_task_comment') + end + + def test_a_muted_unit_keeps_following_the_defaults_underneath + settings = NotificationSetting.for(FactoryBot.create(:user)) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + preference = FactoryBot.create( + :notification_preference, + user: settings.user, + unit: unit, + muted: true + ) + + assert_not preference.customised? + + preference.update!(muted: false) + assert settings.delivers?(unit, 'new_task_comment', :email) + end + + def test_daily_delivery_keeps_its_time_across_a_daylight_saving_transition + settings = FactoryBot.build(:notification_setting, digest_frequency: 'daily', digest_time: '09:00') + from = Time.zone.local(2026, 10, 3, 13, 30, 0) + + next_occurrence = settings.next_occurrence(from) + + assert_equal Date.new(2026, 10, 4), next_occurrence.to_date + assert_equal 9, next_occurrence.hour + end + + def test_weekly_delivery_lands_on_the_selected_weekday + settings = FactoryBot.build( + :notification_setting, + digest_frequency: 'weekly', + digest_weekday: 3, + digest_time: '07:00' + ) + + next_occurrence = settings.next_occurrence(Time.zone.local(2026, 7, 29, 10, 0, 0)) + + assert_equal 3, next_occurrence.to_date.cwday + assert_equal 7, next_occurrence.hour + end + + def test_switching_the_digest_off_processes_notifications_waiting_for_it + settings = FactoryBot.create(:notification_setting) + digested = FactoryBot.create(:notification, recipient: settings.user) + alert = FactoryBot.create(:notification, recipient: settings.user, kind: 'discuss_warning') + + settings.update!(digest_frequency: 'off') + + assert_not_nil digested.reload.email_processed_at + assert_nil settings.reload.next_digest_at + # Alerts never went through the digest, so they are still waiting to send. + assert_nil alert.reload.email_processed_at + end + + def test_unknown_kinds_and_channels_are_rejected + settings = FactoryBot.build(:notification_setting, channels: { 'unknown' => ['smoke'] }) + + assert_not settings.valid? + assert_includes settings.errors[:channels].join, 'unknown' + assert_includes settings.errors[:channels].join, 'smoke' + end + + def test_channels_must_be_an_object + settings = FactoryBot.build(:notification_setting, channels: ['new_task_comment']) + + assert_not settings.valid? + assert_includes settings.errors[:channels], 'must be an object' + end +end diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index b7190e0b07..fc4dc3bb7b 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -16,7 +16,7 @@ def test_tutor_feedback_creates_an_unread_student_notification notification = Notification.find_by(source: comment, recipient: @student) assert_not_nil notification - assert_equal 'feedback_left', notification.kind + assert_equal 'new_task_comment', notification.kind assert_equal @task, notification.task assert_nil notification.read_at end @@ -25,7 +25,7 @@ def test_student_feedback_notifies_staff_but_student_status_does_not comment = @task.add_text_comment(@student, 'Could you clarify this feedback?') @task.add_status_comment(@student, TaskStatus.ready_for_feedback) - assert Notification.exists?(source: comment, recipient: @tutor, kind: 'feedback_left') + assert Notification.exists?(source: comment, recipient: @tutor, kind: 'new_task_comment') assert_not Notification.exists?(recipient: @tutor, kind: 'task_status_changed') end @@ -76,7 +76,7 @@ def test_group_builder_merges_mixed_task_activity groups = NotificationGroupBuilder.new(Notification.where(recipient: @student).unread).groups assert_equal 1, groups.count - assert_equal 3, groups.first[:counts]['feedback_left'] + assert_equal 3, groups.first[:counts]['new_task_comment'] assert_equal 'fix_and_resubmit', groups.first[:latest_status] assert_includes groups.first[:summary], '3 new comments' assert_includes groups.first[:summary], 'Fix and resubmit' @@ -103,7 +103,7 @@ def test_discuss_expiry_supersedes_warning_without_hiding_feedback assert_nil expiry_notification.read_at assert_equal 'critical', group[:severity] assert_equal 1, group[:counts]['discuss_expired'] - assert_equal 1, group[:counts]['feedback_left'] + assert_equal 1, group[:counts]['new_task_comment'] assert_includes group[:summary], 'discussion deadline missed' end @@ -111,12 +111,12 @@ def test_task_tutor_note_and_student_feedback_share_a_group_with_two_actions @task.add_text_comment(@student, 'Can you check this change?') unit_role = @unit.unit_role_for(@tutor) tutor_note = unit_role.add_tutor_note(@unit.main_convenor_user, 'Please follow up', @task.id) - Notification.create_for_tutor_note(tutor_note, @tutor) + Notification.create_for_tutor_note(tutor_note, @tutor, 'moderation_note_added') group = NotificationGroupBuilder.new(Notification.where(recipient: @tutor).unread).groups.first - assert_equal 1, group[:counts]['feedback_left'] - assert_equal 1, group[:counts]['tutor_note'] + assert_equal 1, group[:counts]['new_task_comment'] + assert_equal 1, group[:counts]['moderation_note_added'] assert_equal [tutor_note.id], group[:tutor_note_ids] assert_equal [Notification.find_by!(source: tutor_note).id], group[:tutor_note_notification_ids] assert group.dig(:task, :staff_view) @@ -131,7 +131,7 @@ def test_duplicate_source_event_is_deduplicated_per_recipient project: @project, task: @task, actor: @tutor, - kind: 'feedback_left', + kind: 'new_task_comment', source: comment, deduplication_key: 'same-event', metadata: {} @@ -194,7 +194,7 @@ def test_unread_groups_sort_before_read_history_even_when_read_group_is_urgent :notification, recipient: @student, unit: @unit, - kind: 'feedback_left' + kind: 'new_task_comment' ) groups = NotificationGroupBuilder.new([read_notification, unread_notification]).groups diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index 148ecfd915..3110c8f769 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -1,21 +1,26 @@ require 'test_helper' class NotificationJobsTest < ActiveSupport::TestCase - def test_digest_sends_only_enabled_unread_events + def create_settings(**attributes) + FactoryBot.create(:notification_setting, next_digest_at: 1.minute.ago, **attributes) + end + + def test_digest_sends_only_the_events_enabled_on_the_email_channel + settings = create_settings(channels: { 'new_task_comment' => ['in_app'] }.merge( + NotificationSetting.default_channels.except('new_task_comment') + )) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - user = FactoryBot.create(:user) - preference = FactoryBot.create( - :notification_preference, - user: user, + NotificationPreference.create!( + user: settings.user, unit: unit, - email_categories: ['feedback_left'], - next_digest_at: 1.minute.ago + channels: { 'new_task_comment' => %w[in_app email] } ) - enabled = FactoryBot.create(:notification, recipient: user, unit: unit, kind: 'feedback_left') - disabled = FactoryBot.create(:notification, recipient: user, unit: unit, kind: 'pdf_generation_failed') + + enabled = FactoryBot.create(:notification, recipient: settings.user, unit: unit, kind: 'new_task_comment') + disabled = FactoryBot.create(:notification, recipient: settings.user, kind: 'new_task_comment') assert_emails 1 do - SendNotificationDigestJob.new.perform(preference.id) + SendNotificationDigestJob.new.perform(settings.id) end digest_html = ActionMailer::Base.deliveries.last.html_part.body.to_s @@ -23,46 +28,56 @@ def test_digest_sends_only_enabled_unread_events assert_includes digest_html, '/assets/images/logo.png' assert_includes digest_html, 'notification-box' assert_includes digest_html, 'Unsubscribe' - assert_includes digest_html, "on behalf of #{unit.main_convenor_user.name}" + assert_includes digest_html, unit.code assert_not_nil enabled.reload.email_sent_at assert_not_nil disabled.reload.email_processed_at assert_nil disabled.email_sent_at end + def test_digest_covers_every_unit_in_one_email + settings = create_settings + first = FactoryBot.create(:unit, with_students: false, task_count: 0) + second = FactoryBot.create(:unit, with_students: false, task_count: 0) + FactoryBot.create(:notification, recipient: settings.user, unit: first) + FactoryBot.create(:notification, recipient: settings.user, unit: second) + + assert_emails 1 do + SendNotificationDigestJob.new.perform(settings.id) + end + + digest_html = ActionMailer::Base.deliveries.last.html_part.body.to_s + assert_includes digest_html, first.code + assert_includes digest_html, second.code + end + def test_read_notification_is_not_sent_in_digest - preference = FactoryBot.create(:notification_preference, next_digest_at: 1.minute.ago) + settings = create_settings notification = FactoryBot.create( :notification, - recipient: preference.user, - unit: preference.unit, + recipient: settings.user, read_at: Time.current, email_processed_at: Time.current ) assert_no_emails do - SendNotificationDigestJob.new.perform(preference.id) + SendNotificationDigestJob.new.perform(settings.id) end assert_nil notification.reload.email_sent_at end def test_digest_leaves_an_overseer_event_pending_until_its_email_delay_expires - preference = FactoryBot.create( - :notification_preference, - email_categories: ['overseer_failed'], - next_digest_at: 1.minute.ago - ) + settings = create_settings notification = FactoryBot.create( :notification, - recipient: preference.user, - unit: preference.unit, + recipient: settings.user, kind: 'overseer_failed', metadata: { email_not_before: 20.minutes.from_now.iso8601 } ) assert_no_emails do - SendNotificationDigestJob.new.perform(preference.id) + SendNotificationDigestJob.new.perform(settings.id) end assert_nil notification.reload.email_processed_at @@ -70,16 +85,25 @@ def test_digest_leaves_an_overseer_event_pending_until_its_email_delay_expires end def test_unit_email_master_switch_processes_without_sending - preference = FactoryBot.create(:notification_preference, next_digest_at: 1.minute.ago) - preference.unit.update!(send_notifications: false) - notification = FactoryBot.create( - :notification, - recipient: preference.user, - unit: preference.unit - ) + settings = create_settings + notification = FactoryBot.create(:notification, recipient: settings.user) + notification.unit.update!(send_notifications: false) + + assert_no_emails do + SendNotificationDigestJob.new.perform(settings.id) + end + + assert_not_nil notification.reload.email_processed_at + assert_nil notification.email_sent_at + end + + def test_a_muted_unit_is_processed_without_sending + settings = create_settings + notification = FactoryBot.create(:notification, recipient: settings.user) + NotificationPreference.create!(user: settings.user, unit: notification.unit, muted: true) assert_no_emails do - SendNotificationDigestJob.new.perform(preference.id) + SendNotificationDigestJob.new.perform(settings.id) end assert_not_nil notification.reload.email_processed_at From 41a9432bc789acc4b15ff51a4df7fff53ee57be4 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:55:03 +1000 Subject: [PATCH 09/57] fix: ensure email is sent from no reply account --- app/mailers/notifications_mailer.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 34baa32b00..075111ac9a 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -96,7 +96,11 @@ def notification_digest(recipient, notifications) @group_count = @units.sum { |section| section[:groups].count } subject = "#{@notification_count} new #{'change'.pluralize(@notification_count)} across #{@group_count} #{'notification'.pluralize(@group_count)}" - mail(to: %("#{@recipient.name}" <#{@recipient.email}>), subject: subject) + mail( + to: %("#{@recipient.name}" <#{@recipient.email}>), + from: %("#{@doubtfire_product_name}" ), + subject: subject + ) end def discussion_deadline_approaching(task, sender, expiry_date) From 2cf21f143ffe8ded31b462ad27370a0f6091b84e Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:55:17 +1000 Subject: [PATCH 10/57] fix: replace emdash with hyphen --- app/services/notification_group_builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index edb87ab86c..652b413ee8 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -108,7 +108,7 @@ def summary_for(task, recipient, counts, latest_status) else 'Unit notification' end - "#{subject} — #{details.to_sentence}" + "#{subject} - #{details.to_sentence}" end def pluralize(count, noun) From 34dabbe8e04c5ad621c470c20be34bfa338cc228 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:55:25 +1000 Subject: [PATCH 11/57] feat: improve ui --- .../notifications_mailer/notification_digest.html.erb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index a7ce08aa27..f73afc37ac 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -41,6 +41,10 @@ h1 { font-size: 1.1em; } + h2 { + font-size: 1em; + margin: 25px 0 10px; + } .notification-box { background: #fafafa; border-left: 4px solid #1565c0; @@ -70,8 +74,7 @@

<% @units.each do |section| %> -

<%= section[:unit].code %>

-

<%= section[:unit].name %>

+

<%= section[:unit].code %> - <%= section[:unit].name %>

<% section[:groups].each do |group| %>
From 49f4cee8b9084a130b09c048be143ebe5f328cca Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:02:27 +1000 Subject: [PATCH 12/57] refactor: improve message --- app/mailers/notifications_mailer.rb | 6 +++--- app/views/notifications_mailer/notification_digest.html.erb | 4 ++-- app/views/notifications_mailer/notification_digest.text.erb | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 075111ac9a..e3be1b7ac1 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -88,14 +88,14 @@ def notification_digest(recipient, notifications) add_general @recipient = recipient - @notification_count = notifications.count @notification_url = "#{@doubtfire_host}/notifications" @units = notifications.group_by(&:unit).sort_by { |unit, _| unit.code }.map do |unit, for_unit| { unit: unit, groups: NotificationGroupBuilder.new(for_unit).groups } end - @group_count = @units.sum { |section| section[:groups].count } + # Grouped events, so the count matches the rows the reader can see below. + @notification_count = @units.sum { |section| section[:groups].count } - subject = "#{@notification_count} new #{'change'.pluralize(@notification_count)} across #{@group_count} #{'notification'.pluralize(@group_count)}" + subject = "#{@notification_count} new #{'notification'.pluralize(@notification_count)}" mail( to: %("#{@recipient.name}" <#{@recipient.email}>), from: %("#{@doubtfire_product_name}" ), diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index f73afc37ac..e8cb6d3b48 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -69,8 +69,8 @@

Hi <%= @recipient.first_name %>,

- You have <%= @notification_count %> new <%= 'change'.pluralize(@notification_count) %> - across <%= @group_count %> <%= 'notification'.pluralize(@group_count) %>. + You have <%= @notification_count %> new + <%= 'notification'.pluralize(@notification_count) %> in <%= @doubtfire_product_name %>.

<% @units.each do |section| %> diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index 2915844fda..3345afda80 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -1,6 +1,6 @@ Hi <%= @recipient.first_name %>, -You have <%= @notification_count %> new <%= 'change'.pluralize(@notification_count) %> across <%= @group_count %> <%= 'notification'.pluralize(@group_count) %>. +You have <%= @notification_count %> new <%= 'notification'.pluralize(@notification_count) %> in <%= @doubtfire_product_name %>. <% @units.each do |section| %> <%= section[:unit].code %> - <%= section[:unit].name %> <% section[:groups].each do |group| %> From 1d0d371f983ae9eef96159caa1004e8a2da31e2b Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:14:37 +1000 Subject: [PATCH 13/57] feat: improve notification wording --- app/services/notification_group_builder.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index 652b413ee8..b1544ca938 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -96,11 +96,11 @@ def summary_for(task, recipient, counts, latest_status) details << 'discussion deadline missed' if counts['discuss_expired'].positive? details << 'discussion deadline approaching' if counts['discuss_warning'].positive? details << 'submission PDF generation failed' if counts['pdf_generation_failed'].positive? - details << 'automated assessment failed' if counts['overseer_failed'].positive? + details << 'overseer assessment failed' if counts['overseer_failed'].positive? moderation_notes = Notification::MODERATION_KINDS.sum { |kind| counts[kind] } details << pluralize(moderation_notes, 'moderation note') if moderation_notes.positive? details << pluralize(counts['new_task_comment'], 'new comment') if counts['new_task_comment'].positive? - details << "status changed to #{latest_status.to_s.humanize}" if latest_status.present? + details << "status changed to #{status_name(latest_status)}" if latest_status.present? subject = if task @@ -108,7 +108,13 @@ def summary_for(task, recipient, counts, latest_status) else 'Unit notification' end - "#{subject} - #{details.to_sentence}" + # Sentence case, so a single detail reads as a heading and several still join + # into one readable sentence. + "#{subject} - #{details.to_sentence.upcase_first}" + end + + def status_name(status_key) + TaskStatus.status_for_name(status_key.to_s)&.name || status_key.to_s.humanize end def pluralize(count, noun) From 02cd1776aedd5b2c3b318ce9259e731d751a2fae Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:51:37 +1000 Subject: [PATCH 14/57] feat: add additional detail --- app/services/notification_group_builder.rb | 34 +++++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index b1544ca938..297143cd37 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -63,7 +63,9 @@ def serialize(items) latest_at: latest.created_at, tutor_note_ids: tutor_notes.filter_map { |notification| notification.metadata['tutor_note_id'] }, tutor_note_unit_role_id: tutor_notes.first&.metadata&.fetch('unit_role_id', nil), - summary: summary_for(task, latest.recipient, counts, latest_status) + overseer_assessment_id: overseer_assessment_id(items), + detail: detail_for(counts, latest_status), + summary: "#{subject_for(task, latest.recipient)} - #{detail_for(counts, latest_status)}" } end @@ -91,7 +93,23 @@ def severity_for(items) 'normal' end - def summary_for(task, recipient, counts, latest_status) + # The newest failed run in the group, so opening the notification can jump + # straight to that report. + def overseer_assessment_id(items) + items.select { |notification| notification.kind == 'overseer_failed' }.max_by(&:created_at)&.source_id + end + + def subject_for(task, recipient) + return 'Unit notification' if task.nil? + + return task.task_definition.abbreviation if task.project.student == recipient + + "#{task.task_definition.abbreviation} for #{task.project.student.name}" + end + + # What happened, without the task it happened to, so callers can show the two + # separately rather than splitting the summary back apart. + def detail_for(counts, latest_status) details = [] details << 'discussion deadline missed' if counts['discuss_expired'].positive? details << 'discussion deadline approaching' if counts['discuss_warning'].positive? @@ -100,17 +118,11 @@ def summary_for(task, recipient, counts, latest_status) moderation_notes = Notification::MODERATION_KINDS.sum { |kind| counts[kind] } details << pluralize(moderation_notes, 'moderation note') if moderation_notes.positive? details << pluralize(counts['new_task_comment'], 'new comment') if counts['new_task_comment'].positive? - details << "status changed to #{status_name(latest_status)}" if latest_status.present? - - subject = - if task - task.project.student == recipient ? task.task_definition.abbreviation : "#{task.task_definition.abbreviation} for #{task.project.student.name}" - else - 'Unit notification' - end + details << "task status changed to #{status_name(latest_status)}" if latest_status.present? + # Sentence case, so a single detail reads as a heading and several still join # into one readable sentence. - "#{subject} - #{details.to_sentence.upcase_first}" + details.to_sentence.upcase_first end def status_name(status_key) From 83864df1bab763e3bfcbc7b2b802a581982658c9 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:55:07 +1000 Subject: [PATCH 15/57] chore: fix tests --- app/models/notification.rb | 2 +- app/models/notification_setting.rb | 6 +++--- test/api/notifications_api_test.rb | 4 ++-- test/models/notification_setting_test.rb | 2 +- test/models/notification_test.rb | 4 ++-- test/sidekiq/notification_jobs_test.rb | 2 ++ test/sidekiq/scheduled_job_test.rb | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/models/notification.rb b/app/models/notification.rb index 6d3235043f..1f0b41fc6c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -145,7 +145,7 @@ def self.create_event(**attributes) return if withdrawn_student?(attributes[:project], recipient) settings = NotificationSetting.for(recipient) - channels = settings.channels_for_unit(unit.id, kind) + channels = settings.channels_for_unit_id(unit.id, kind) return if channels.empty? notification = find_or_initialize_by( diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 8a8f9af6a7..a315bcee1a 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -32,7 +32,7 @@ def self.default_channels # The channels a unit delivers a kind on, honouring its preference when it has # one. A muted unit delivers nothing. - def channels_for_unit(unit_id, kind) + def channels_for_unit_id(unit_id, kind) preference = preference_for(unit_id) return [] if preference&.muted @@ -40,13 +40,13 @@ def channels_for_unit(unit_id, kind) end def delivers?(unit, kind, channel) - channels_for_unit(unit&.id, kind).include?(channel.to_s) + channels_for_unit_id(unit&.id, kind).include?(channel.to_s) end # Notifications are always recorded so the digest has something to send, so the # in-app list has to filter them out rather than rely on them never existing. def shows_in_app?(unit_id, kind) - channels_for_unit(unit_id, kind).include?('in_app') + channels_for_unit_id(unit_id, kind).include?('in_app') end def advance_digest!(from: Time.current) diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index a0e023b406..e4eadd8b80 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -81,7 +81,7 @@ def test_a_kind_switched_off_in_app_is_hidden_but_still_emailed settings = NotificationSetting.for(@student) settings.update!(channels: settings.channels.merge('new_task_comment' => ['email'])) - get_json '/api/notifications' + get '/api/notifications' assert_equal 200, last_response.status assert_empty last_response_body['groups'] @@ -92,7 +92,7 @@ def test_a_kind_switched_off_in_app_is_hidden_but_still_emailed end def test_settings_start_from_the_defaults - get_json '/api/notification_settings' + get '/api/notification_settings' assert_equal 200, last_response.status assert_equal 'weekly', last_response_body['digest_frequency'] diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 28832e847c..79533e09bd 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -39,7 +39,7 @@ def test_a_muted_unit_delivers_nothing unit = FactoryBot.create(:unit, with_students: false, task_count: 0) FactoryBot.create(:notification_preference, user: settings.user, unit: unit, muted: true) - assert_empty settings.channels_for_unit(unit, 'new_task_comment') + assert_empty settings.channels_for_unit_id(unit.id, 'new_task_comment') end def test_a_muted_unit_keeps_following_the_defaults_underneath diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index fc4dc3bb7b..8fd9bc0802 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -79,7 +79,7 @@ def test_group_builder_merges_mixed_task_activity assert_equal 3, groups.first[:counts]['new_task_comment'] assert_equal 'fix_and_resubmit', groups.first[:latest_status] assert_includes groups.first[:summary], '3 new comments' - assert_includes groups.first[:summary], 'Fix and resubmit' + assert_includes groups.first[:summary], 'task status changed to Fix and Resubmit' end def test_discuss_expiry_supersedes_warning_without_hiding_feedback @@ -104,7 +104,7 @@ def test_discuss_expiry_supersedes_warning_without_hiding_feedback assert_equal 'critical', group[:severity] assert_equal 1, group[:counts]['discuss_expired'] assert_equal 1, group[:counts]['new_task_comment'] - assert_includes group[:summary], 'discussion deadline missed' + assert_includes group[:summary], 'Discussion deadline missed' end def test_task_tutor_note_and_student_feedback_share_a_group_with_two_actions diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index 3110c8f769..0290b1440f 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -1,6 +1,8 @@ require 'test_helper' class NotificationJobsTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + def create_settings(**attributes) FactoryBot.create(:notification_setting, next_digest_at: 1.minute.ago, **attributes) end diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index 35396ef827..631004a00a 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,7 +6,7 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.destroy_all! Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 7, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 10, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count From be398b8e77e58b08fcd10fe89f9108d2d386e1e2 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:02:47 +1000 Subject: [PATCH 16/57] chore: clean up migration: --- ...45_create_notifications_and_preferences.rb | 35 ++++++++++++------ db/schema.rb | 37 ++++++++++--------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/db/migrate/20260825000645_create_notifications_and_preferences.rb b/db/migrate/20260825000645_create_notifications_and_preferences.rb index cd96455885..5ae18d967f 100644 --- a/db/migrate/20260825000645_create_notifications_and_preferences.rb +++ b/db/migrate/20260825000645_create_notifications_and_preferences.rb @@ -8,19 +8,27 @@ def change t.references :actor, null: true t.string :kind, null: false, limit: 64 - t.string :source_type, null: true, limit: 64 - t.bigint :source_id, null: true + # Identifies the event, so cron re-runs and job retries cannot raise it twice. t.string :deduplication_key, null: false, limit: 191 - t.json :metadata, null: false + + # What raised the notification + t.references :task_comment, null: true + t.references :overseer_assessment, null: true, index: false + t.references :tutor_note, null: true, index: false + + # Set only for the kinds that need them + t.references :task_status, null: true, index: false # what a status change moved to + t.references :unit_role, null: true, index: false # whose moderation notes were written on + t.date :discuss_deadline, null: true # when the task has to be discussed by t.datetime :read_at t.datetime :email_processed_at t.datetime :email_sent_at + t.datetime :email_not_before # holds a failed run back until the student has had a chance to read it t.timestamps end - add_index :notifications, [:source_type, :source_id] add_index :notifications, [:recipient_id, :deduplication_key], unique: true, name: 'index_notifications_on_recipient_and_deduplication_key' @@ -31,12 +39,16 @@ def change add_index :notifications, [:recipient_id, :task_id, :read_at], name: 'index_notifications_on_recipient_task_read' - # One row per user: the single digest schedule, and the channel defaults that - # every unit follows until it is given its own notification_preference. + # One row per user. `channels` maps each notification to the channels it is + # delivered on, and applies to every unit the user is in: + # + # { "new_task_comment" => ["in_app", "email"], "overseer_failed" => [] } + # create_table :notification_settings do |t| t.references :user, null: false, index: { unique: true } t.json :channels, null: false + t.string :digest_frequency, null: false, default: 'weekly', limit: 16 t.string :digest_time, null: false, default: '07:00', limit: 5 t.integer :digest_weekday, null: false, default: 1 @@ -49,9 +61,10 @@ def change add_index :notification_settings, :next_digest_at - # One row per unit the user has changed. Channels are null while the unit - # still follows the user's defaults and has only been muted. - create_table :notification_preferences do |t| + # One row per unit the user has changed, holding only what differs from their + # notification_settings. A muted unit sends nothing, and null channels mean + # the unit still uses the channels from notification_settings. + create_table :notification_unit_overrides do |t| t.references :user, null: false, index: false t.references :unit, null: false @@ -61,8 +74,8 @@ def change t.timestamps end - add_index :notification_preferences, [:user_id, :unit_id], + add_index :notification_unit_overrides, [:user_id, :unit_id], unique: true, - name: 'index_notification_preferences_on_user_and_unit' + name: 'index_notification_unit_overrides_on_user_and_unit' end end diff --git a/db/schema.rb b/db/schema.rb index 68989b11da..a218f8feb2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -372,18 +372,6 @@ t.index ["task_id"], name: "index_moderated_tasks_on_task_id" end - create_table "notification_preferences", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| - t.bigint "user_id", null: false - t.bigint "unit_id", null: false - t.boolean "muted", default: false, null: false - t.text "channels", size: :long, collation: "utf8mb4_bin" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["unit_id"], name: "index_notification_preferences_on_unit_id" - t.index ["user_id", "unit_id"], name: "index_notification_preferences_on_user_and_unit", unique: true - t.check_constraint "json_valid(`channels`)", name: "channels" - end - create_table "notification_settings", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "user_id", null: false t.text "channels", size: :long, null: false, collation: "utf8mb4_bin" @@ -400,6 +388,18 @@ t.check_constraint "json_valid(`channels`)", name: "channels" end + create_table "notification_unit_overrides", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "unit_id", null: false + t.boolean "muted", default: false, null: false + t.text "channels", size: :long, collation: "utf8mb4_bin" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["unit_id"], name: "index_notification_unit_overrides_on_unit_id" + t.index ["user_id", "unit_id"], name: "index_notification_unit_overrides_on_user_and_unit", unique: true + t.check_constraint "json_valid(`channels`)", name: "channels" + end + create_table "notifications", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "recipient_id", null: false t.bigint "unit_id", null: false @@ -407,13 +407,17 @@ t.bigint "task_id" t.bigint "actor_id" t.string "kind", limit: 64, null: false - t.string "source_type", limit: 64 - t.bigint "source_id" t.string "deduplication_key", limit: 191, null: false - t.text "metadata", size: :long, null: false, collation: "utf8mb4_bin" + t.bigint "task_comment_id" + t.bigint "overseer_assessment_id" + t.bigint "tutor_note_id" + t.bigint "task_status_id" + t.bigint "unit_role_id" + t.date "discuss_deadline" t.datetime "read_at" t.datetime "email_processed_at" t.datetime "email_sent_at" + t.datetime "email_not_before" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["actor_id"], name: "index_notifications_on_actor_id" @@ -422,10 +426,9 @@ t.index ["recipient_id", "read_at", "created_at"], name: "index_notifications_on_recipient_read_created" t.index ["recipient_id", "task_id", "read_at"], name: "index_notifications_on_recipient_task_read" t.index ["recipient_id", "unit_id", "email_processed_at"], name: "index_notifications_for_email_delivery" - t.index ["source_type", "source_id"], name: "index_notifications_on_source_type_and_source_id" + t.index ["task_comment_id"], name: "index_notifications_on_task_comment_id" t.index ["task_id"], name: "index_notifications_on_task_id" t.index ["unit_id"], name: "index_notifications_on_unit_id" - t.check_constraint "json_valid(`metadata`)", name: "metadata" end create_table "overflow_task_claim_logs", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| From 9d575bc4f2c6b10a40dd2a625d8a220b505fd573 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:13:35 +1000 Subject: [PATCH 17/57] refactor: update schema fields --- app/api/notifications_api.rb | 30 +++---- app/models/comments/task_comment.rb | 2 +- .../concerns/has_notification_channels.rb | 35 -------- app/models/notification.rb | 87 ++++++++----------- app/models/notification_preference.rb | 15 ---- app/models/notification_setting.rb | 19 ++-- app/models/notification_unit_override.rb | 17 ++++ app/models/overseer_assessment.rb | 2 +- app/models/task.rb | 1 - app/models/tutor_note.rb | 2 +- app/models/unit.rb | 2 +- app/models/user.rb | 2 +- app/services/notification_group_builder.rb | 13 ++- .../send_immediate_notification_job.rb | 3 +- app/sidekiq/send_notification_digest_job.rb | 3 +- .../notification_channels_validator.rb | 20 +++++ test/api/notifications_api_test.rb | 8 +- test/factories/notifications.rb | 3 +- test/models/notification_setting_test.rb | 6 +- test/models/notification_test.rb | 36 +++----- test/sidekiq/notification_jobs_test.rb | 6 +- 21 files changed, 135 insertions(+), 177 deletions(-) delete mode 100644 app/models/concerns/has_notification_channels.rb delete mode 100644 app/models/notification_preference.rb create mode 100644 app/models/notification_unit_override.rb create mode 100644 app/validators/notification_channels_validator.rb diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 65d786573f..474c92fa87 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -27,12 +27,12 @@ def shown_in_app(notifications) end def unread_group_count - rows = current_user.received_notifications.unread.pluck(:task_id, :unit_id, :kind, :metadata) - rows.select { |_task_id, unit_id, kind, _metadata| notification_settings.shows_in_app?(unit_id, kind) }.map do |task_id, unit_id, kind, metadata| + rows = current_user.received_notifications.unread.pluck(:task_id, :unit_id, :kind, :unit_role_id) + rows.select { |_task_id, unit_id, kind, _unit_role_id| notification_settings.shows_in_app?(unit_id, kind) }.map do |task_id, unit_id, kind, unit_role_id| if task_id.present? "task:#{task_id}" elsif Notification::MODERATION_KINDS.include?(kind) - "tutor-notes:#{unit_id}:#{metadata['unit_role_id']}" + "tutor-notes:#{unit_id}:#{unit_role_id}" else "unit:#{unit_id}:#{kind}" end @@ -49,28 +49,28 @@ def serialize_settings(settings) weekly_summary: settings.weekly_summary, next_digest_at: settings.next_digest_at, last_digest_at: settings.last_digest_at, - units: current_user.notification_preferences.order(:unit_id).map { |preference| serialize_preference(preference) } + units: current_user.notification_unit_overrides.order(:unit_id).map { |override| serialize_override(override) } } end - def serialize_preference(preference) + def serialize_override(override) { - unit_id: preference.unit_id, - muted: preference.muted, - channels: preference.channels + unit_id: override.unit_id, + muted: override.muted, + channels: override.channels } end - # The units sent are the whole set that departs from the defaults, so any unit - # missing from it has been reset and no longer needs a row. - def replace_unit_preferences(units) + # The units sent are the whole set that departs from the settings, so any unit + # missing from it has been reset and no longer needs an override. + def replace_unit_overrides(units) accessible = accessible_unit_ids wanted = Array(units).select { |unit| accessible.include?(unit[:unit_id]) } - current_user.notification_preferences.where.not(unit_id: wanted.map { |unit| unit[:unit_id] }).destroy_all + current_user.notification_unit_overrides.where.not(unit_id: wanted.map { |unit| unit[:unit_id] }).destroy_all wanted.each do |unit| - preference = current_user.notification_preferences.find_or_initialize_by(unit_id: unit[:unit_id]) - preference.update!(muted: unit[:muted], channels: unit[:channels]) + override = current_user.notification_unit_overrides.find_or_initialize_by(unit_id: unit[:unit_id]) + override.update!(muted: unit[:muted], channels: unit[:channels]) end end @@ -186,7 +186,7 @@ def accessible_unit_ids NotificationSetting.transaction do settings.update!(changes.except(:units)) - replace_unit_preferences(changes[:units]) if changes.key?(:units) + replace_unit_overrides(changes[:units]) if changes.key?(:units) end serialize_settings(settings) diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb index c53d76d06a..cb4dd9cd8e 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -18,7 +18,7 @@ class TaskComment < ApplicationRecord belongs_to :recipient, class_name: 'User', optional: false has_many :comments_read_receipts, class_name: 'CommentsReadReceipts', dependent: :destroy, inverse_of: :task_comment - has_many :notifications, as: :source, dependent: :destroy + has_many :notifications, dependent: :destroy has_many :comment_read_cursors, foreign_key: :last_read_comment_id, inverse_of: :last_read_comment, diff --git a/app/models/concerns/has_notification_channels.rb b/app/models/concerns/has_notification_channels.rb deleted file mode 100644 index bf194977d2..0000000000 --- a/app/models/concerns/has_notification_channels.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -# Shared handling of the `channels` column, which maps a notification kind to the -# channels it is delivered on: { 'new_task_comment' => ['in_app', 'email'] }. -module HasNotificationChannels - extend ActiveSupport::Concern - - included do - attribute :channels, :json - - validate :channels_are_known - end - - # The channels enabled for a kind, or none when the kind is switched off. - def channels_for(kind) - Array(channels && channels[kind.to_s]) - end - - private - - def channels_are_known - return if channels.nil? - - unless channels.is_a?(Hash) - errors.add(:channels, 'must be an object') - return - end - - unknown_kinds = channels.keys - Notification::KINDS - errors.add(:channels, "includes unknown kinds: #{unknown_kinds.join(', ')}") if unknown_kinds.any? - - unknown_channels = channels.values.flatten.uniq - Notification::CHANNELS - errors.add(:channels, "includes unknown channels: #{unknown_channels.join(', ')}") if unknown_channels.any? - end -end diff --git a/app/models/notification.rb b/app/models/notification.rb index 1f0b41fc6c..e105e49a3c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -18,24 +18,28 @@ class Notification < ApplicationRecord DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze - attribute :metadata, :json, default: -> { {} } - belongs_to :recipient, class_name: 'User', inverse_of: :received_notifications belongs_to :unit belongs_to :project, optional: true belongs_to :task, optional: true belongs_to :actor, class_name: 'User', optional: true, inverse_of: :acted_notifications - belongs_to :source, polymorphic: true, optional: true + + # What raised the notification. + belongs_to :task_comment, optional: true + belongs_to :overseer_assessment, optional: true + belongs_to :tutor_note, optional: true + + # Extras, set only by the kinds that have them. + belongs_to :task_status, optional: true + belongs_to :unit_role, optional: true validates :kind, inclusion: { in: KINDS } validates :deduplication_key, presence: true, uniqueness: { scope: :recipient_id } - validate :metadata_is_an_object scope :unread, -> { where(read_at: nil) } scope :recently_read, -> { where(read_at: 30.days.ago..) } scope :email_pending, -> { unread.where(email_processed_at: nil) } - - before_validation :normalize_metadata + scope :email_ready, ->(at = Time.current) { where(email_not_before: [nil, ..at]) } def self.create_for_task_comment(comment) kind = kind_for_comment(comment) @@ -60,9 +64,10 @@ def self.create_for_task_comment(comment) task: recipient_task, actor: comment.user, kind: kind, - source: comment, + task_comment: comment, deduplication_key: "task-comment:#{comment.id}:#{kind}", - metadata: metadata_for_comment(comment, recipient_task) + task_status: comment.is_a?(TaskStatusComment) ? comment.task_status : nil, + discuss_deadline: discuss_deadline_for(comment, recipient_task) ) SendImmediateNotificationJob.perform_async(notification.id) if notification && DISCUSS_KINDS.include?(kind) @@ -80,7 +85,7 @@ def self.create_for_overseer(assessment) recipient: recipient, task: recipient_task, kind: 'overseer_failed' - ).where.not(source: assessment).unread + ).where.not(overseer_assessment: assessment).unread ) next if assessment_comment.seen_by?(recipient) @@ -91,13 +96,9 @@ def self.create_for_overseer(assessment) task: recipient_task, actor: assessment.task.project.tutor_for(assessment.task.task_definition), kind: 'overseer_failed', - source: assessment, + overseer_assessment: assessment, deduplication_key: "overseer-assessment:#{assessment.id}:failed", - metadata: { - email_not_before: ( - assessment.updated_at + OverseerAssessment.student_notification_grace_period - ).iso8601 - } + email_not_before: assessment.updated_at + OverseerAssessment.student_notification_grace_period ) end end @@ -113,9 +114,7 @@ def self.create_pdf_failure(task) task: recipient_task, actor: task.project.tutor_for(task.task_definition), kind: 'pdf_generation_failed', - source: task, - deduplication_key: "pdf-generation:#{task.id}:#{version}:failed", - metadata: {} + deduplication_key: "pdf-generation:#{task.id}:#{version}:failed" ) end end @@ -128,12 +127,9 @@ def self.create_for_tutor_note(tutor_note, recipient, kind) task: tutor_note.task, actor: tutor_note.user, kind: kind, - source: tutor_note, - deduplication_key: "tutor-note:#{tutor_note.id}", - metadata: { - unit_role_id: tutor_note.unit_role_id, - tutor_note_id: tutor_note.id - } + tutor_note: tutor_note, + unit_role: tutor_note.unit_role, + deduplication_key: "tutor-note:#{tutor_note.id}" ) end @@ -160,8 +156,13 @@ def self.create_event(**attributes) task: attributes[:task], actor: attributes[:actor], kind: kind, - source: attributes[:source], - metadata: attributes.fetch(:metadata, {}) + task_comment: attributes[:task_comment], + overseer_assessment: attributes[:overseer_assessment], + tutor_note: attributes[:tutor_note], + task_status: attributes[:task_status], + unit_role: attributes[:unit_role], + discuss_deadline: attributes[:discuss_deadline], + email_not_before: attributes[:email_not_before] ) notification.save! notification.update!(email_processed_at: Time.current) unless channels.include?('email') @@ -199,7 +200,7 @@ def self.reopen_from_comment(comment, recipient) # Keep email_processed_at unchanged so manually reopening a comment never resends email. # rubocop:disable Rails/SkipsModelValidations - where(recipient: recipient, source_type: 'TaskComment', source_id: later_comment_ids) + where(recipient: recipient, task_comment_id: later_comment_ids) .update_all(read_at: nil, updated_at: Time.current) # rubocop:enable Rails/SkipsModelValidations end @@ -223,12 +224,7 @@ def recipient_withdrawn? end def email_ready?(at: Time.current) - email_not_before = metadata['email_not_before'] || metadata[:email_not_before] - return true if email_not_before.blank? - - Time.zone.parse(email_not_before) <= at - rescue ArgumentError, TypeError - true + email_not_before.blank? || email_not_before <= at end def self.kind_for_comment(comment) @@ -252,11 +248,10 @@ def self.kind_for_comment(comment) end end - def self.metadata_for_comment(comment, recipient_task) - result = {} - result[:status] = comment.task_status.status_key if comment.is_a?(TaskStatusComment) - result[:deadline] = recipient_task.unit.discuss_timeout_expiry_date(recipient_task)&.iso8601 if comment.content_type == DiscussTimeoutComment.warning - result + def self.discuss_deadline_for(comment, recipient_task) + return nil unless comment.content_type == DiscussTimeoutComment.warning + + recipient_task.unit.discuss_timeout_expiry_date(recipient_task) end def self.recipients_for_comment(comment) @@ -284,19 +279,5 @@ def self.student_actor?(comment) comment.user == comment.project.student || comment.task.role_for(comment.user).in?(%i[student group_member]) end - def metadata_is_an_object - errors.add(:metadata, 'must be a JSON object') unless metadata.is_a?(Hash) - end - - def normalize_metadata - self.metadata ||= {} - return unless metadata.is_a?(String) - - parsed = JSON.parse(metadata) - self.metadata = parsed if parsed.is_a?(Hash) - rescue JSON::ParserError - nil - end - - private_class_method :kind_for_comment, :metadata_for_comment, :recipients_for_comment, :student_actor? + private_class_method :kind_for_comment, :discuss_deadline_for, :recipients_for_comment, :student_actor? end diff --git a/app/models/notification_preference.rb b/app/models/notification_preference.rb deleted file mode 100644 index 1a5c178839..0000000000 --- a/app/models/notification_preference.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -# One unit's departure from a user's notification settings. A unit with no -# preference follows the user's defaults; a preference with null channels has -# only been muted, and still follows them. -class NotificationPreference < ApplicationRecord - include HasNotificationChannels - - belongs_to :user - belongs_to :unit - - def customised? - channels.present? - end -end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index a315bcee1a..225ef2b015 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -3,13 +3,14 @@ # A user's notification settings: the one digest schedule they choose, and the # channel defaults every unit follows until it is customised. class NotificationSetting < ApplicationRecord - include HasNotificationChannels - FREQUENCIES = %w[off hourly daily weekly].freeze TIME_FORMAT = /\A(?:[01]\d|2[0-3]):[0-5]\d\z/ + attribute :channels, :json + belongs_to :user + validates :channels, notification_channels: true validates :digest_frequency, inclusion: { in: FREQUENCIES } validates :digest_time, format: { with: TIME_FORMAT } validates :digest_weekday, inclusion: { in: 1..7 } @@ -30,13 +31,13 @@ def self.default_channels Notification::KINDS.index_with { %w[in_app email] } end - # The channels a unit delivers a kind on, honouring its preference when it has + # The channels a unit delivers a kind on, honouring its override when it has # one. A muted unit delivers nothing. def channels_for_unit_id(unit_id, kind) - preference = preference_for(unit_id) - return [] if preference&.muted + override = override_for(unit_id) + return [] if override&.muted - preference&.customised? ? preference.channels_for(kind) : channels_for(kind) + Array((override&.customised? ? override.channels : channels)[kind.to_s]) end def delivers?(unit, kind, channel) @@ -68,11 +69,11 @@ def next_occurrence(from = Time.current) private - def preference_for(unit_id) + def override_for(unit_id) return nil if unit_id.nil? - @preferences_by_unit ||= user.notification_preferences.index_by(&:unit_id) - @preferences_by_unit[unit_id] + @overrides_by_unit ||= user.notification_unit_overrides.index_by(&:unit_id) + @overrides_by_unit[unit_id] end def apply_defaults diff --git a/app/models/notification_unit_override.rb b/app/models/notification_unit_override.rb new file mode 100644 index 0000000000..bacb3de5da --- /dev/null +++ b/app/models/notification_unit_override.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# One unit's departure from a user's notification settings. A unit without an +# override follows those settings; an override with no channels has only been +# muted, and still follows them. +class NotificationUnitOverride < ApplicationRecord + attribute :channels, :json + + belongs_to :user + belongs_to :unit + + validates :channels, notification_channels: true + + def customised? + channels.present? + end +end diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb index 98e26baf37..e651153684 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -6,7 +6,7 @@ class OverseerAssessment < ApplicationRecord has_one :project, through: :task has_many :assessment_comments, as: :commentable, dependent: :destroy has_many :overseer_step_results, dependent: :destroy - has_many :notifications, as: :source, dependent: :destroy + has_many :notifications, dependent: :destroy validates :status, presence: true validates :task_id, presence: true diff --git a/app/models/task.rb b/app/models/task.rb index de89b3b737..2192fca849 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -143,7 +143,6 @@ def specific_permission_hash(role, perm_hash, _other) has_many :test_attempts, dependent: :destroy has_many :session_activities, dependent: :destroy has_many :notifications, dependent: :destroy - has_many :source_notifications, as: :source, class_name: 'Notification', dependent: :destroy delegate :unit, to: :project delegate :student, to: :project diff --git a/app/models/tutor_note.rb b/app/models/tutor_note.rb index ee7ad0eed4..24aef62440 100644 --- a/app/models/tutor_note.rb +++ b/app/models/tutor_note.rb @@ -3,7 +3,7 @@ class TutorNote < ApplicationRecord belongs_to :user belongs_to :task, optional: true belongs_to :reply_to, class_name: "TutorNote", optional: true - has_many :notifications, as: :source, dependent: :destroy + has_many :notifications, dependent: :destroy def task_definition_id task&.task_definition&.id diff --git a/app/models/unit.rb b/app/models/unit.rb index be8d67ed7c..4c4cba8806 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -184,7 +184,7 @@ def role_for(user) has_many :unit_content_sites, dependent: :destroy has_many :unit_content_links, dependent: :destroy has_many :notifications, dependent: :destroy - has_many :notification_preferences, dependent: :destroy + has_many :notification_unit_overrides, dependent: :destroy has_many :comments, through: :projects has_many :tasks, through: :projects diff --git a/app/models/user.rb b/app/models/user.rb index b574e24733..cf5eff380a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -193,7 +193,7 @@ def token_for_text?(a_token, token_type) foreign_key: :actor_id, dependent: :nullify, inverse_of: :actor - has_many :notification_preferences, dependent: :destroy + has_many :notification_unit_overrides, dependent: :destroy has_one :notification_setting, dependent: :destroy # Model validations/constraints diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index 297143cd37..7ad80d6271 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -25,8 +25,7 @@ def grouping_key(notification) state_key = notification.read_at ? "read:#{notification.read_at.to_f}" : 'unread' return "#{state_key}:task:#{notification.task_id}" if notification.task_id.present? - unit_role_id = notification.metadata['unit_role_id'] || notification.metadata[:unit_role_id] - return "#{state_key}:tutor-notes:#{unit_role_id}" if Notification::MODERATION_KINDS.include?(notification.kind) + return "#{state_key}:tutor-notes:#{notification.unit_role_id}" if Notification::MODERATION_KINDS.include?(notification.kind) "#{state_key}:unit:#{notification.unit_id}:#{notification.kind}" end @@ -38,8 +37,8 @@ def serialize(items) latest_status = items .select { |notification| notification.kind == 'task_status_changed' } .max_by(&:created_at) - &.metadata - &.fetch('status', nil) + &.task_status + &.status_key tutor_notes = items .select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } .sort_by { |notification| [notification.created_at, notification.id] } @@ -61,8 +60,8 @@ def serialize(items) read: items.all? { |notification| notification.read_at.present? }, read_at: items.filter_map(&:read_at).max, latest_at: latest.created_at, - tutor_note_ids: tutor_notes.filter_map { |notification| notification.metadata['tutor_note_id'] }, - tutor_note_unit_role_id: tutor_notes.first&.metadata&.fetch('unit_role_id', nil), + tutor_note_ids: tutor_notes.filter_map(&:tutor_note_id), + tutor_note_unit_role_id: tutor_notes.first&.unit_role_id, overseer_assessment_id: overseer_assessment_id(items), detail: detail_for(counts, latest_status), summary: "#{subject_for(task, latest.recipient)} - #{detail_for(counts, latest_status)}" @@ -96,7 +95,7 @@ def severity_for(items) # The newest failed run in the group, so opening the notification can jump # straight to that report. def overseer_assessment_id(items) - items.select { |notification| notification.kind == 'overseer_failed' }.max_by(&:created_at)&.source_id + items.select { |notification| notification.kind == 'overseer_failed' }.max_by(&:created_at)&.overseer_assessment_id end def subject_for(task, recipient) diff --git a/app/sidekiq/send_immediate_notification_job.rb b/app/sidekiq/send_immediate_notification_job.rb index f5326846f5..9ca63d47d4 100644 --- a/app/sidekiq/send_immediate_notification_job.rb +++ b/app/sidekiq/send_immediate_notification_job.rb @@ -26,11 +26,10 @@ def perform(notification_id) mail = if notification.kind == 'discuss_warning' - deadline = notification.metadata['deadline'] NotificationsMailer.discussion_deadline_approaching( notification.task, sender, - deadline.present? ? Date.iso8601(deadline) : notification.unit.discuss_timeout_expiry_date(notification.task) + notification.discuss_deadline || notification.unit.discuss_timeout_expiry_date(notification.task) ) else NotificationsMailer.discussion_deadline_missed(notification.task, sender) diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index bee9a37fa5..c6f22701b2 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -16,9 +16,10 @@ def perform(setting_id) ready = setting.user .received_notifications .email_pending + .email_ready(now) .where.not(kind: Notification::DISCUSS_KINDS) .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) - .select { |notification| notification.email_ready?(at: now) } + .to_a deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification) } mark_processed(skipped, now) diff --git a/app/validators/notification_channels_validator.rb b/app/validators/notification_channels_validator.rb new file mode 100644 index 0000000000..25863d311e --- /dev/null +++ b/app/validators/notification_channels_validator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Checks the `channels` column, which maps a notification kind to the channels it +# is delivered on: { 'new_task_comment' => ['in_app', 'email'] }. +class NotificationChannelsValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.nil? + + unless value.is_a?(Hash) + record.errors.add(attribute, 'must be an object') + return + end + + unknown_kinds = value.keys - Notification::KINDS + record.errors.add(attribute, "includes unknown kinds: #{unknown_kinds.join(', ')}") if unknown_kinds.any? + + unknown_channels = value.values.flatten.uniq - Notification::CHANNELS + record.errors.add(attribute, "includes unknown channels: #{unknown_channels.join(', ')}") if unknown_channels.any? + end +end diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index e4eadd8b80..c095666f3e 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -116,7 +116,7 @@ def test_updating_settings_stores_the_schedule_and_the_units_that_differ end def test_updating_settings_drops_units_that_no_longer_differ - NotificationPreference.create!(user: @student, unit: @unit, muted: true) + NotificationUnitOverride.create!(user: @student, unit: @unit, muted: true) put_json '/api/notification_settings', channels: { new_task_comment: ['in_app'] }, @@ -127,7 +127,7 @@ def test_updating_settings_drops_units_that_no_longer_differ units: [] assert_equal 200, last_response.status - assert_empty @student.notification_preferences.reload + assert_empty @student.notification_unit_overrides.reload end def test_updating_settings_ignores_units_the_user_cannot_access @@ -153,7 +153,7 @@ def test_updating_settings_rejects_an_unknown_frequency def test_updating_settings_leaves_out_what_was_not_sent put_json '/api/notification_settings', digest_frequency: 'daily' - NotificationPreference.create!(user: @student, unit: @unit, muted: true) + NotificationUnitOverride.create!(user: @student, unit: @unit, muted: true) # The client only sends what changed, so an absent key must not clear anything. put_json '/api/notification_settings', digest_time: '06:00' @@ -161,6 +161,6 @@ def test_updating_settings_leaves_out_what_was_not_sent assert_equal 200, last_response.status assert_equal 'daily', last_response_body['digest_frequency'] assert_equal '06:00', last_response_body['digest_time'] - assert_equal 1, @student.notification_preferences.reload.count + assert_equal 1, @student.notification_unit_overrides.reload.count end end diff --git a/test/factories/notifications.rb b/test/factories/notifications.rb index 3c12f002c2..3c1330d3ac 100644 --- a/test/factories/notifications.rb +++ b/test/factories/notifications.rb @@ -4,7 +4,6 @@ unit { create(:unit, with_students: false, task_count: 0) } kind { 'new_task_comment' } sequence(:deduplication_key) { |number| "factory-notification-#{number}" } - metadata { {} } end factory :notification_setting do @@ -14,7 +13,7 @@ digest_weekday { 1 } end - factory :notification_preference do + factory :notification_unit_override do user unit { create(:unit, with_students: false, task_count: 0) } muted { false } diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 79533e09bd..362fbdafcb 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -24,7 +24,7 @@ def test_a_customised_unit_uses_its_own_channels settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) FactoryBot.create( - :notification_preference, + :notification_unit_override, user: settings.user, unit: unit, channels: { 'new_task_comment' => ['push'] } @@ -37,7 +37,7 @@ def test_a_customised_unit_uses_its_own_channels def test_a_muted_unit_delivers_nothing settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - FactoryBot.create(:notification_preference, user: settings.user, unit: unit, muted: true) + FactoryBot.create(:notification_unit_override, user: settings.user, unit: unit, muted: true) assert_empty settings.channels_for_unit_id(unit.id, 'new_task_comment') end @@ -46,7 +46,7 @@ def test_a_muted_unit_keeps_following_the_defaults_underneath settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) preference = FactoryBot.create( - :notification_preference, + :notification_unit_override, user: settings.user, unit: unit, muted: true diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 8fd9bc0802..d2d7109a49 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -13,7 +13,7 @@ def setup def test_tutor_feedback_creates_an_unread_student_notification comment = @task.add_text_comment(@tutor, 'Please revise this section') - notification = Notification.find_by(source: comment, recipient: @student) + notification = Notification.find_by(task_comment: comment, recipient: @student) assert_not_nil notification assert_equal 'new_task_comment', notification.kind @@ -25,7 +25,7 @@ def test_student_feedback_notifies_staff_but_student_status_does_not comment = @task.add_text_comment(@student, 'Could you clarify this feedback?') @task.add_status_comment(@student, TaskStatus.ready_for_feedback) - assert Notification.exists?(source: comment, recipient: @tutor, kind: 'new_task_comment') + assert Notification.exists?(task_comment: comment, recipient: @tutor, kind: 'new_task_comment') assert_not Notification.exists?(recipient: @tutor, kind: 'task_status_changed') end @@ -49,23 +49,23 @@ def test_staff_group_feedback_fans_out_to_each_students_corresponding_task TaskStatus.ready_for_feedback ) - notifications = Notification.where(source: comment).order(:recipient_id) + notifications = Notification.where(task_comment: comment).order(:recipient_id) assert_equal group.projects.map(&:student).sort_by(&:id), notifications.map(&:recipient).sort_by(&:id) assert_equal tasks.map(&:id).sort, notifications.map(&:task_id).sort - assert_not Notification.exists?(source: group_member_status) + assert_not Notification.exists?(task_comment: group_member_status) end def test_only_latest_staff_status_notification_remains_unread first_comment = @task.add_status_comment(@tutor, TaskStatus.fix_and_resubmit) second_comment = @task.add_status_comment(@tutor, TaskStatus.complete) - first_notification = Notification.find_by!(source: first_comment, recipient: @student) - second_notification = Notification.find_by!(source: second_comment, recipient: @student) + first_notification = Notification.find_by!(task_comment: first_comment, recipient: @student) + second_notification = Notification.find_by!(task_comment: second_comment, recipient: @student) assert_not_nil first_notification.read_at assert_nil second_notification.read_at - assert_equal 'complete', second_notification.metadata['status'] + assert_equal TaskStatus.complete, second_notification.task_status assert_equal 1, Notification.where(recipient: @student, task: @task, kind: 'task_status_changed').unread.count end @@ -95,8 +95,8 @@ def test_discuss_expiry_supersedes_warning_without_hiding_feedback 'Your discussion deadline has passed' ) - warning_notification = Notification.find_by!(source: warning, recipient: @student) - expiry_notification = Notification.find_by!(source: expiry, recipient: @student) + warning_notification = Notification.find_by!(task_comment: warning, recipient: @student) + expiry_notification = Notification.find_by!(task_comment: expiry, recipient: @student) group = NotificationGroupBuilder.new(Notification.where(recipient: @student).unread).groups.first assert_not_nil warning_notification.read_at @@ -118,7 +118,7 @@ def test_task_tutor_note_and_student_feedback_share_a_group_with_two_actions assert_equal 1, group[:counts]['new_task_comment'] assert_equal 1, group[:counts]['moderation_note_added'] assert_equal [tutor_note.id], group[:tutor_note_ids] - assert_equal [Notification.find_by!(source: tutor_note).id], group[:tutor_note_notification_ids] + assert_equal [Notification.find_by!(tutor_note: tutor_note).id], group[:tutor_note_notification_ids] assert group.dig(:task, :staff_view) assert_equal @student.name, group.dig(:task, :student_name) end @@ -132,9 +132,8 @@ def test_duplicate_source_event_is_deduplicated_per_recipient task: @task, actor: @tutor, kind: 'new_task_comment', - source: comment, + task_comment: comment, deduplication_key: 'same-event', - metadata: {} } first = Notification.create_event(**attributes) @@ -144,13 +143,6 @@ def test_duplicate_source_event_is_deduplicated_per_recipient assert_equal 1, Notification.where(recipient: @student, deduplication_key: 'same-event').count end - def test_metadata_normalizes_a_text_backed_json_value - notification = FactoryBot.build(:notification, metadata: { source: 'comment' }.to_json) - - assert notification.valid? - assert_equal({ 'source' => 'comment' }, notification.metadata) - end - def test_overseer_failure_is_created_immediately_but_email_is_held_for_the_grace_period submission_history = FactoryBot.create(:submission_history, task: @task) assessment = FactoryBot.create( @@ -165,7 +157,7 @@ def test_overseer_failure_is_created_immediately_but_email_is_held_for_the_grace grace_period = OverseerAssessment.student_notification_grace_period notification = Notification.find_by!( - source: assessment, + overseer_assessment: assessment, recipient: @student, kind: 'overseer_failed' ) @@ -175,7 +167,7 @@ def test_overseer_failure_is_created_immediately_but_email_is_held_for_the_grace def test_destroying_a_source_removes_its_notification comment = @task.add_text_comment(@tutor, 'Temporary feedback') - notification = Notification.find_by!(source: comment, recipient: @student) + notification = Notification.find_by!(task_comment: comment, recipient: @student) comment.destroy! @@ -205,7 +197,7 @@ def test_unread_groups_sort_before_read_history_even_when_read_group_is_urgent def test_marking_task_read_processes_email_and_marking_source_unread_reopens_in_app_only comment = @task.add_text_comment(@tutor, 'Read me') - notification = Notification.find_by!(source: comment, recipient: @student) + notification = Notification.find_by!(task_comment: comment, recipient: @student) Notification.mark_task_read(@student, @task) notification.reload diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index 0290b1440f..4af9e263f0 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -12,7 +12,7 @@ def test_digest_sends_only_the_events_enabled_on_the_email_channel NotificationSetting.default_channels.except('new_task_comment') )) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - NotificationPreference.create!( + NotificationUnitOverride.create!( user: settings.user, unit: unit, channels: { 'new_task_comment' => %w[in_app email] } @@ -75,7 +75,7 @@ def test_digest_leaves_an_overseer_event_pending_until_its_email_delay_expires :notification, recipient: settings.user, kind: 'overseer_failed', - metadata: { email_not_before: 20.minutes.from_now.iso8601 } + email_not_before: 20.minutes.from_now ) assert_no_emails do @@ -102,7 +102,7 @@ def test_unit_email_master_switch_processes_without_sending def test_a_muted_unit_is_processed_without_sending settings = create_settings notification = FactoryBot.create(:notification, recipient: settings.user) - NotificationPreference.create!(user: settings.user, unit: notification.unit, muted: true) + NotificationUnitOverride.create!(user: settings.user, unit: notification.unit, muted: true) assert_no_emails do SendNotificationDigestJob.new.perform(settings.id) From a6ae8b065cfb63355eabdfa1e0a1942c0966a92d Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:00:09 +1000 Subject: [PATCH 18/57] refactor: fix overseer workflow --- app/models/notification.rb | 14 +++++++++---- app/models/notification_setting.rb | 4 ++-- ...eate_pending_overseer_notifications_job.rb | 17 ---------------- config/schedule.yml | 4 ---- lib/tasks/overseer_notifications.rake | 12 ++++++++--- test/sidekiq/notification_jobs_test.rb | 20 ++++++++++++------- test/sidekiq/scheduled_job_test.rb | 2 +- 7 files changed, 35 insertions(+), 38 deletions(-) delete mode 100644 app/sidekiq/create_pending_overseer_notifications_job.rb diff --git a/app/models/notification.rb b/app/models/notification.rb index e105e49a3c..608b537603 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -15,6 +15,8 @@ class Notification < ApplicationRecord CHANNELS = %w[in_app email push].freeze + # Emailed on their own rather than held for the digest. Overseer and PDF + # failures are not here: they are emailed by their own cron tasks. DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze @@ -57,7 +59,7 @@ def self.create_for_task_comment(comment) resolve_for(recipient: recipient, task: recipient_task, kinds: 'discuss_warning') end - notification = create_event( + create_event( recipient: recipient, unit: recipient_task.unit, project: recipient_task.project, @@ -69,8 +71,6 @@ def self.create_for_task_comment(comment) task_status: comment.is_a?(TaskStatusComment) ? comment.task_status : nil, discuss_deadline: discuss_deadline_for(comment, recipient_task) ) - - SendImmediateNotificationJob.perform_async(notification.id) if notification && DISCUSS_KINDS.include?(kind) end end @@ -165,7 +165,13 @@ def self.create_event(**attributes) email_not_before: attributes[:email_not_before] ) notification.save! - notification.update!(email_processed_at: Time.current) unless channels.include?('email') + + if !channels.include?('email') + notification.update!(email_processed_at: Time.current) + elsif DISCUSS_KINDS.include?(kind) + SendImmediateNotificationJob.perform_at(notification.email_not_before || Time.current, notification.id) + end + notification rescue ActiveRecord::RecordNotUnique find_by(recipient: recipient, deduplication_key: deduplication_key) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 225ef2b015..2e42e917a0 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -95,8 +95,8 @@ def saved_change_to_digest_off? digest_frequency == 'off' && saved_change_to_digest_frequency? end - # Nothing is waiting for a digest that will never run. Alerts are unaffected - - # they never went through the digest in the first place. + # Nothing is waiting for a digest that will never run. Discussion deadlines are + # unaffected - they are emailed on their own and never went through the digest. def process_pending_notifications now = Time.current # rubocop:disable Rails/SkipsModelValidations diff --git a/app/sidekiq/create_pending_overseer_notifications_job.rb b/app/sidekiq/create_pending_overseer_notifications_job.rb deleted file mode 100644 index e5291ab28c..0000000000 --- a/app/sidekiq/create_pending_overseer_notifications_job.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class CreatePendingOverseerNotificationsJob - include Sidekiq::Job - - sidekiq_options lock: :until_executed, - lock_args_method: ->(_args) { ['create-pending-overseer-notifications'] }, - on_conflict: :reject, - retry: 1 - - def perform - OverseerAssessment.awaiting_student_failure_notification.find_each do |assessment| - Notification.create_for_overseer(assessment) - assessment.update!(student_notified_at: Time.current) - end - end -end diff --git a/config/schedule.yml b/config/schedule.yml index d591b65aaf..ea392a1fca 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -32,10 +32,6 @@ poll_notification_digests: cron: "every 5 minutes" class: "PollNotificationDigestsJob" -create_pending_overseer_notifications: - cron: "every 5 minutes" - class: "CreatePendingOverseerNotificationsJob" - prune_notifications: cron: "every day at 2am" class: "PruneNotificationsJob" diff --git a/lib/tasks/overseer_notifications.rake b/lib/tasks/overseer_notifications.rake index 2b69ee3f9e..35f01ba50a 100644 --- a/lib/tasks/overseer_notifications.rake +++ b/lib/tasks/overseer_notifications.rake @@ -6,15 +6,21 @@ namespace :overseer_notifications do assessments.group_by(&:project).each do |project, project_assessments| tasks = project_assessments.map(&:task).uniq + mark_notified = -> { project_assessments.each { |assessment| assessment.update!(student_notified_at: Time.current) } } + + # The student may have switched this email off, or muted the unit. Mark them + # notified anyway, so they are not reconsidered every ten minutes. + unless NotificationSetting.for(project.student).delivers?(project.unit, 'overseer_failed', :email) + mark_notified.call + next + end begin mail = PortfolioEvidenceMailer.overseer_assessment_failed(project, tasks) next if mail.blank? mail.deliver_now - project_assessments.each do |assessment| - assessment.update!(student_notified_at: Time.current) - end + mark_notified.call rescue StandardError => e Rails.logger.error "Failed to send overseer assessment email for project #{project.id}!\n#{e.message}" end diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index 4af9e263f0..74180f8d60 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -69,14 +69,20 @@ def test_read_notification_is_not_sent_in_digest assert_nil notification.reload.email_sent_at end - def test_digest_leaves_an_overseer_event_pending_until_its_email_delay_expires + def test_an_unread_overseer_failure_is_carried_by_the_digest settings = create_settings - notification = FactoryBot.create( - :notification, - recipient: settings.user, - kind: 'overseer_failed', - email_not_before: 20.minutes.from_now - ) + notification = FactoryBot.create(:notification, recipient: settings.user, kind: 'overseer_failed') + + assert_emails 1 do + SendNotificationDigestJob.new.perform(settings.id) + end + + assert_not_nil notification.reload.email_sent_at + end + + def test_discussion_deadlines_are_left_for_their_own_email + settings = create_settings + notification = FactoryBot.create(:notification, recipient: settings.user, kind: 'discuss_warning') assert_no_emails do SendNotificationDigestJob.new.perform(settings.id) diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index 631004a00a..baab1a0691 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,7 +6,7 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.destroy_all! Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 10, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 9, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count From 7d9f755ec250e1c7d8c70e72cdf651c103e028cb Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:11:37 +1000 Subject: [PATCH 19/57] chore: rename job --- app/models/notification.rb | 4 ++-- ...n_job.rb => send_discuss_deadline_email_job.rb} | 4 +++- test/mailers/unit_mail_test.rb | 14 +++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) rename app/sidekiq/{send_immediate_notification_job.rb => send_discuss_deadline_email_job.rb} (86%) diff --git a/app/models/notification.rb b/app/models/notification.rb index 608b537603..f02912b045 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -15,7 +15,7 @@ class Notification < ApplicationRecord CHANNELS = %w[in_app email push].freeze - # Emailed on their own rather than held for the digest. Overseer and PDF + # Emailed as they are raised rather than held for the digest. Overseer and PDF # failures are not here: they are emailed by their own cron tasks. DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze @@ -169,7 +169,7 @@ def self.create_event(**attributes) if !channels.include?('email') notification.update!(email_processed_at: Time.current) elsif DISCUSS_KINDS.include?(kind) - SendImmediateNotificationJob.perform_at(notification.email_not_before || Time.current, notification.id) + SendDiscussDeadlineEmailJob.perform_at(notification.email_not_before || Time.current, notification.id) end notification diff --git a/app/sidekiq/send_immediate_notification_job.rb b/app/sidekiq/send_discuss_deadline_email_job.rb similarity index 86% rename from app/sidekiq/send_immediate_notification_job.rb rename to app/sidekiq/send_discuss_deadline_email_job.rb index 9ca63d47d4..335f9811c6 100644 --- a/app/sidekiq/send_immediate_notification_job.rb +++ b/app/sidekiq/send_discuss_deadline_email_job.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class SendImmediateNotificationJob +# Emails a discussion deadline as soon as it is raised, rather than holding it for +# the digest. Skipped if the student has already read it, or has the email off. +class SendDiscussDeadlineEmailJob include Sidekiq::Job sidekiq_options retry: 5 diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 3b7fbb44c5..8c35edf9d7 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -110,22 +110,22 @@ def test_discuss_timeout_notifications_send_emails task.update!(moved_to_discuss_at: 8.days.ago) assert_equal 1, unit.notify_discuss_timeouts! - assert_equal 1, SendImmediateNotificationJob.jobs.count + assert_equal 1, SendDiscussDeadlineEmailJob.jobs.count - approaching_job = SendImmediateNotificationJob.jobs.shift + approaching_job = SendDiscussDeadlineEmailJob.jobs.shift assert_emails 1 do - SendImmediateNotificationJob.new.perform(*approaching_job['args']) + SendDiscussDeadlineEmailJob.new.perform(*approaching_job['args']) end assert_includes ActionMailer::Base.deliveries.last.subject, 'Discussion deadline approaching' task.update!(moved_to_discuss_at: 15.days.ago) assert_equal 1, unit.notify_discuss_timeouts! - assert_equal 1, SendImmediateNotificationJob.jobs.count + assert_equal 1, SendDiscussDeadlineEmailJob.jobs.count - missed_job = SendImmediateNotificationJob.jobs.shift + missed_job = SendDiscussDeadlineEmailJob.jobs.shift assert_emails 1 do - SendImmediateNotificationJob.new.perform(*missed_job['args']) + SendDiscussDeadlineEmailJob.new.perform(*missed_job['args']) end assert_includes ActionMailer::Base.deliveries.last.subject, 'Discussion deadline missed' end @@ -147,7 +147,7 @@ def test_discuss_timeout_notifications_are_not_queued_for_unenrolled_students assert_equal 1, unit.notify_discuss_timeouts! assert_empty Notification.where(recipient: project.student, task: task) - assert_empty SendImmediateNotificationJob.jobs + assert_empty SendDiscussDeadlineEmailJob.jobs end def test_batch_feedback_updates_unenrolled_students_without_emailing_them From 71fea4c9646179819bea89bbe3ac8a4385c87512 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:54:21 +1000 Subject: [PATCH 20/57] feat: add portfolio notifications --- app/api/notifications_api.rb | 6 ++-- app/mailers/notifications_mailer.rb | 2 +- app/mailers/portfolio_evidence_mailer.rb | 2 +- app/models/notification.rb | 24 +++++++++++++ app/models/notification_setting.rb | 7 ++++ app/models/portfolio_evidence.rb | 11 +++++- app/models/project.rb | 2 +- app/models/unit_role.rb | 2 +- app/services/notification_group_builder.rb | 11 ++++-- app/sidekiq/accept_submission_job.rb | 7 ++++ .../notification_digest.html.erb | 6 ++++ .../notification_digest.text.erb | 2 ++ lib/tasks/generate_pdfs.rake | 13 +++---- lib/tasks/maintenance.rake | 4 ++- test/api/notifications_api_test.rb | 12 +++++++ test/models/notification_setting_test.rb | 14 ++++++++ test/models/notification_test.rb | 34 ++++++++++++++++++- 17 files changed, 141 insertions(+), 18 deletions(-) diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 474c92fa87..0b2f1e1221 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -27,10 +27,12 @@ def shown_in_app(notifications) end def unread_group_count - rows = current_user.received_notifications.unread.pluck(:task_id, :unit_id, :kind, :unit_role_id) - rows.select { |_task_id, unit_id, kind, _unit_role_id| notification_settings.shows_in_app?(unit_id, kind) }.map do |task_id, unit_id, kind, unit_role_id| + rows = current_user.received_notifications.unread.pluck(:task_id, :project_id, :unit_id, :kind, :unit_role_id) + rows.select { |_task_id, _project_id, unit_id, kind, _unit_role_id| notification_settings.shows_in_app?(unit_id, kind) }.map do |task_id, project_id, unit_id, kind, unit_role_id| if task_id.present? "task:#{task_id}" + elsif Notification::PORTFOLIO_KINDS.include?(kind) + "portfolio:#{project_id}" elsif Notification::MODERATION_KINDS.include?(kind) "tutor-notes:#{unit_id}:#{unit_role_id}" else diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index e3be1b7ac1..3b67d38bf0 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -5,7 +5,7 @@ class NotificationsMailer < ApplicationMailer def add_general @doubtfire_host = Doubtfire::Application.config.institution[:host] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] - @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + @unsubscribe_url = "#{@doubtfire_host}/notifications/settings" end def weekly_staff_summary(unit_role, summary_stats) diff --git a/app/mailers/portfolio_evidence_mailer.rb b/app/mailers/portfolio_evidence_mailer.rb index 84e14fc8fe..870f17bccc 100644 --- a/app/mailers/portfolio_evidence_mailer.rb +++ b/app/mailers/portfolio_evidence_mailer.rb @@ -2,7 +2,7 @@ class PortfolioEvidenceMailer < ApplicationMailer def add_general @doubtfire_host = Doubtfire::Application.config.institution[:host] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] - @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + @unsubscribe_url = "#{@doubtfire_host}/notifications/settings" end def task_pdf_failed(project, tasks) diff --git a/app/models/notification.rb b/app/models/notification.rb index f02912b045..9e9b835dfc 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -11,6 +11,8 @@ class Notification < ApplicationRecord moderation_note_added moderation_note_reply moderation_note_from_mentee + portfolio_ready + portfolio_failed ].freeze CHANNELS = %w[in_app email push].freeze @@ -19,6 +21,7 @@ class Notification < ApplicationRecord # failures are not here: they are emailed by their own cron tasks. DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze + PORTFOLIO_KINDS = %w[portfolio_ready portfolio_failed].freeze belongs_to :recipient, class_name: 'User', inverse_of: :received_notifications belongs_to :unit @@ -133,6 +136,27 @@ def self.create_for_tutor_note(tutor_note, recipient, kind) ) end + def self.create_for_portfolio(project, success:) + recipient = project.student + kind = success ? 'portfolio_ready' : 'portfolio_failed' + notification = create_event( + recipient: recipient, + unit: project.unit, + project: project, + actor: project.main_convenor_user, + kind: kind, + deduplication_key: "portfolio:#{project.id}:#{project.updated_at.to_f}:#{kind}" + ) + return if notification.nil? + + mark_read( + where(recipient: recipient, project: project, kind: PORTFOLIO_KINDS) + .where.not(id: notification.id) + .unread + ) + notification + end + def self.create_event(**attributes) recipient = attributes.fetch(:recipient) unit = attributes.fetch(:unit) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 2e42e917a0..5739e1b147 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -44,6 +44,13 @@ def delivers?(unit, kind, channel) channels_for_unit_id(unit&.id, kind).include?(channel.to_s) end + def weekly_summary_for?(unit) + return false unless weekly_summary + + override = user.notification_unit_overrides.find_by(unit_id: unit&.id) + !override&.muted + end + # Notifications are always recorded so the digest has something to send, so the # in-app list has to filter them out rather than rely on them never existing. def shows_in_app?(unit_id, kind) diff --git a/app/models/portfolio_evidence.rb b/app/models/portfolio_evidence.rb index 76e64af6e2..8f2b1bdde3 100644 --- a/app/models/portfolio_evidence.rb +++ b/app/models/portfolio_evidence.rb @@ -71,8 +71,17 @@ def self.process_new_to_pdf(my_source) end end - errors.each_value do |tasks| + errors.each do |project, tasks| tasks.each { |task| Notification.create_pdf_failure(task) } + + next unless NotificationSetting.for(project.student).delivers?(project.unit, 'pdf_generation_failed', :email) + + logger.info "emailing task notification to #{project.student.name}" + begin + PortfolioEvidenceMailer.task_pdf_failed(project, tasks).deliver_now + rescue StandardError => e + logger.error "Failed to send task pdf failed email for project #{project.id}!\n#{e.message}" + end end end diff --git a/app/models/project.rb b/app/models/project.rb index cdfeebe5b1..010404e101 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -690,7 +690,7 @@ def send_weekly_status_email(summary_stats, middle_of_unit) # summary_stats[:revert][main_convenor_user] << self # end - return unless student.receive_feedback_notifications + return unless NotificationSetting.for(student).weekly_summary_for?(unit) return if portfolio_exists? && !middle_of_unit begin diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index 68fc0f5e09..65754c6e67 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -201,7 +201,7 @@ def populate_summary_stats(summary_stats, tutorial_stream, tutorial, row) end def send_weekly_status_email(summary_stats) - return unless user.receive_feedback_notifications + return unless NotificationSetting.for(user).weekly_summary_for?(unit) begin NotificationsMailer.weekly_staff_summary(self, summary_stats).deliver_now diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index 7ad80d6271..682f0bb9ed 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -24,6 +24,7 @@ def groups def grouping_key(notification) state_key = notification.read_at ? "read:#{notification.read_at.to_f}" : 'unread' return "#{state_key}:task:#{notification.task_id}" if notification.task_id.present? + return "#{state_key}:portfolio:#{notification.project_id}" if Notification::PORTFOLIO_KINDS.include?(notification.kind) return "#{state_key}:tutor-notes:#{notification.unit_role_id}" if Notification::MODERATION_KINDS.include?(notification.kind) @@ -52,6 +53,7 @@ def serialize(items) code: latest.unit.code, name: latest.unit.name }, + project_id: latest.project_id, task: task_details(task, latest.recipient), counts: counts, event_count: items.count, @@ -64,7 +66,7 @@ def serialize(items) tutor_note_unit_role_id: tutor_notes.first&.unit_role_id, overseer_assessment_id: overseer_assessment_id(items), detail: detail_for(counts, latest_status), - summary: "#{subject_for(task, latest.recipient)} - #{detail_for(counts, latest_status)}" + summary: "#{subject_for(task, latest.recipient, counts)} - #{detail_for(counts, latest_status)}" } end @@ -86,7 +88,7 @@ def task_details(task, recipient) def severity_for(items) kinds = items.map(&:kind) - return 'critical' if kinds.intersect?(%w[discuss_expired pdf_generation_failed]) + return 'critical' if kinds.intersect?(%w[discuss_expired pdf_generation_failed portfolio_failed]) return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed] + Notification::MODERATION_KINDS) 'normal' @@ -98,7 +100,8 @@ def overseer_assessment_id(items) items.select { |notification| notification.kind == 'overseer_failed' }.max_by(&:created_at)&.overseer_assessment_id end - def subject_for(task, recipient) + def subject_for(task, recipient, counts) + return 'Portfolio' if Notification::PORTFOLIO_KINDS.any? { |kind| counts[kind].positive? } return 'Unit notification' if task.nil? return task.task_definition.abbreviation if task.project.student == recipient @@ -114,6 +117,8 @@ def detail_for(counts, latest_status) details << 'discussion deadline approaching' if counts['discuss_warning'].positive? details << 'submission PDF generation failed' if counts['pdf_generation_failed'].positive? details << 'overseer assessment failed' if counts['overseer_failed'].positive? + details << 'portfolio compilation failed' if counts['portfolio_failed'].positive? + details << 'portfolio ready to review' if counts['portfolio_ready'].positive? moderation_notes = Notification::MODERATION_KINDS.sum { |kind| counts[kind] } details << pluralize(moderation_notes, 'moderation note') if moderation_notes.positive? details << pluralize(counts['new_task_comment'], 'new comment') if counts['new_task_comment'].positive? diff --git a/app/sidekiq/accept_submission_job.rb b/app/sidekiq/accept_submission_job.rb index d0bc6c4fe7..c98c88d427 100644 --- a/app/sidekiq/accept_submission_job.rb +++ b/app/sidekiq/accept_submission_job.rb @@ -31,6 +31,13 @@ def perform(task_id, user_id, accepted_tii_eula, test_submission) logger.error e Notification.create_pdf_failure(task) + if NotificationSetting.for(task.project.student).delivers?(task.unit, 'pdf_generation_failed', :email) + begin + PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver_now + rescue StandardError => mail_error + logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{mail_error.message}" + end + end begin # Notify system admin diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index e8cb6d3b48..da4454b6e5 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -85,6 +85,12 @@ View this task

+ <% elsif group[:project_id] %> +

+ + View your portfolio + +

<% end %>
<% end %> diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index 3345afda80..d6ea11ad14 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -7,6 +7,8 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati - <%= group[:summary] %> <% if group[:task] %> <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> +<% elsif group[:project_id] %> + <%= @doubtfire_host %>/projects/<%= group[:project_id] %>/portfolio <% end %> <% end %> <% end %> diff --git a/lib/tasks/generate_pdfs.rake b/lib/tasks/generate_pdfs.rake index 5db26d07e7..df4cdcfe54 100644 --- a/lib/tasks/generate_pdfs.rake +++ b/lib/tasks/generate_pdfs.rake @@ -148,16 +148,17 @@ namespace :submission do end end - next unless project.student.receive_portfolio_notifications + notification = Notification.create_for_portfolio(project, success: success) + next if notification.nil? + + settings = NotificationSetting.for(project.student) + next unless settings.delivers?(project.unit, notification.kind, :email) logger.info "emailing portfolio notification to #{project.student.name}" begin - if success - PortfolioEvidenceMailer.portfolio_ready(project).deliver_now - else - PortfolioEvidenceMailer.portfolio_failed(project).deliver_now - end + mail = success ? PortfolioEvidenceMailer.portfolio_ready(project) : PortfolioEvidenceMailer.portfolio_failed(project) + mail.deliver_now if mail.present? rescue StandardError => e logger.error "Failed to send portfolio email for project #{project.id}!\n#{e.message}" end diff --git a/lib/tasks/maintenance.rake b/lib/tasks/maintenance.rake index 8986d838cf..bfe670fd02 100644 --- a/lib/tasks/maintenance.rake +++ b/lib/tasks/maintenance.rake @@ -45,7 +45,9 @@ namespace :maintenance do end def notify_failed_submission(task, message) - if task.project.student.receive_task_notifications + Notification.create_pdf_failure(task) + + if NotificationSetting.for(task.project.student).delivers?(task.unit, 'pdf_generation_failed', :email) begin PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver_now rescue StandardError => e diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index c095666f3e..1127250f05 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -42,6 +42,18 @@ def test_unread_count_counts_a_task_group_instead_of_each_event assert_equal 1, last_response_body['count'] end + def test_portfolio_notifications_are_grouped_by_project + @student.received_notifications.destroy_all + Notification.create_for_portfolio(@project, success: true) + + get '/api/notifications' + + assert_equal 200, last_response.status + assert_equal 1, last_response_body['unread_count'] + assert_equal @project.id, last_response_body.dig('groups', 0, 'project_id') + assert_equal({ 'portfolio_ready' => 1 }, last_response_body.dig('groups', 0, 'counts')) + end + def test_get_filters_groups_by_category_and_search get '/api/notifications', state: 'unread', diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 362fbdafcb..b4634572aa 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -42,6 +42,20 @@ def test_a_muted_unit_delivers_nothing assert_empty settings.channels_for_unit_id(unit.id, 'new_task_comment') end + def test_weekly_summary_honours_the_global_setting_and_unit_mute + settings = NotificationSetting.for(FactoryBot.create(:user)) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + + assert settings.weekly_summary_for?(unit) + + FactoryBot.create(:notification_unit_override, user: settings.user, unit: unit, muted: true) + assert_not settings.weekly_summary_for?(unit) + + settings.update!(weekly_summary: false) + other_unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + assert_not settings.weekly_summary_for?(other_unit) + end + def test_a_muted_unit_keeps_following_the_defaults_underneath settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index d2d7109a49..c010ea3165 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -133,7 +133,7 @@ def test_duplicate_source_event_is_deduplicated_per_recipient actor: @tutor, kind: 'new_task_comment', task_comment: comment, - deduplication_key: 'same-event', + deduplication_key: 'same-event' } first = Notification.create_event(**attributes) @@ -165,6 +165,38 @@ def test_overseer_failure_is_created_immediately_but_email_is_held_for_the_grace assert_not notification.email_ready?(at: Time.current + grace_period - 1.minute) end + def test_portfolio_result_uses_the_notification_ledger + notification = Notification.create_for_portfolio(@project, success: true) + group = NotificationGroupBuilder.new([notification]).groups.first + + assert_equal 'portfolio_ready', notification.kind + assert_equal @project, notification.project + assert_equal @student, notification.recipient + assert_nil notification.task + assert_nil notification.read_at + assert_equal @project.id, group[:project_id] + assert_equal 'Portfolio - portfolio ready to review', group[:summary] + end + + def test_a_new_portfolio_result_resolves_the_previous_one + ready = Notification.create_for_portfolio(@project, success: true) + @project.update!(updated_at: 1.second.from_now) + + failed = Notification.create_for_portfolio(@project, success: false) + + assert_not_nil ready.reload.read_at + assert_nil failed.read_at + assert_equal 'portfolio_failed', failed.kind + end + + def test_retrying_the_same_portfolio_result_does_not_mark_it_read + first = Notification.create_for_portfolio(@project, success: true) + duplicate = Notification.create_for_portfolio(@project, success: true) + + assert_equal first, duplicate + assert_nil first.reload.read_at + end + def test_destroying_a_source_removes_its_notification comment = @task.add_text_comment(@tutor, 'Temporary feedback') notification = Notification.find_by!(task_comment: comment, recipient: @student) From 9f71253177e755a0688d8d3ea2d361e107e05dc0 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:41:03 +1000 Subject: [PATCH 21/57] refactor: ensure discussion deadlines behave like overseer notifications --- app/models/notification.rb | 7 +-- app/models/notification_setting.rb | 4 +- app/models/unit.rb | 12 +++++ .../send_discuss_deadline_email_job.rb | 46 ------------------- app/sidekiq/send_discuss_timeout_email_job.rb | 26 +++++++++++ app/sidekiq/send_notification_digest_job.rb | 1 - test/mailers/unit_mail_test.rb | 35 +++++++++++--- test/models/notification_setting_test.rb | 3 +- test/sidekiq/notification_jobs_test.rb | 8 ++-- 9 files changed, 73 insertions(+), 69 deletions(-) delete mode 100644 app/sidekiq/send_discuss_deadline_email_job.rb create mode 100644 app/sidekiq/send_discuss_timeout_email_job.rb diff --git a/app/models/notification.rb b/app/models/notification.rb index 9e9b835dfc..5e78537732 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -17,9 +17,6 @@ class Notification < ApplicationRecord CHANNELS = %w[in_app email push].freeze - # Emailed as they are raised rather than held for the digest. Overseer and PDF - # failures are not here: they are emailed by their own cron tasks. - DISCUSS_KINDS = %w[discuss_warning discuss_expired].freeze MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze PORTFOLIO_KINDS = %w[portfolio_ready portfolio_failed].freeze @@ -190,10 +187,8 @@ def self.create_event(**attributes) ) notification.save! - if !channels.include?('email') + unless channels.include?('email') notification.update!(email_processed_at: Time.current) - elsif DISCUSS_KINDS.include?(kind) - SendDiscussDeadlineEmailJob.perform_at(notification.email_not_before || Time.current, notification.id) end notification diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 5739e1b147..c57f0833fd 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -102,14 +102,12 @@ def saved_change_to_digest_off? digest_frequency == 'off' && saved_change_to_digest_frequency? end - # Nothing is waiting for a digest that will never run. Discussion deadlines are - # unaffected - they are emailed on their own and never went through the digest. + # Nothing is left waiting for a digest that will never run. def process_pending_notifications now = Time.current # rubocop:disable Rails/SkipsModelValidations user.received_notifications .email_pending - .where.not(kind: Notification::DISCUSS_KINDS) .update_all(email_processed_at: now, updated_at: now) # rubocop:enable Rails/SkipsModelValidations end diff --git a/app/models/unit.rb b/app/models/unit.rb index 4c4cba8806..2459be3a44 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -325,6 +325,7 @@ def warn_discuss_timeout_task(task, actor, teaching_breaks: nil) raise ActiveRecord::Rollback if comment.blank? task.update!(notified_discuss_warning_at: Time.zone.now) + queue_discuss_timeout_email(task, actor, :approaching, expiry_date) created_comment = true end @@ -350,6 +351,7 @@ def expire_discuss_timeout_task(task, actor) raise ActiveRecord::Rollback end + queue_discuss_timeout_email(task, actor, :missed) created_comment = true end @@ -369,6 +371,16 @@ def formatted_discuss_timeout_date(date) "#{result} #{date.year}" end + def queue_discuss_timeout_email(task, actor, type, expiry_date = nil) + return unless send_notifications + return unless task.project.enrolled + + kind = type == :approaching ? 'discuss_warning' : 'discuss_expired' + return unless NotificationSetting.for(task.project.student).delivers?(self, kind, :email) + + SendDiscussTimeoutEmailJob.perform_async(task.id, actor.id, type.to_s, expiry_date&.iso8601) + end + def detailed_name "#{name} #{teaching_period.present? ? teaching_period.detailed_name : start_date.strftime('%Y-%m-%d')}" end diff --git a/app/sidekiq/send_discuss_deadline_email_job.rb b/app/sidekiq/send_discuss_deadline_email_job.rb deleted file mode 100644 index 335f9811c6..0000000000 --- a/app/sidekiq/send_discuss_deadline_email_job.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -# Emails a discussion deadline as soon as it is raised, rather than holding it for -# the digest. Skipped if the student has already read it, or has the email off. -class SendDiscussDeadlineEmailJob - include Sidekiq::Job - - sidekiq_options retry: 5 - - def perform(notification_id) - notification = Notification.find(notification_id) - return if notification.email_processed_at.present? - - settings = NotificationSetting.for(notification.recipient) - unless notification.read_at.nil? && - notification.unit.send_notifications && - !notification.recipient_withdrawn? && - settings.delivers?(notification.unit, notification.kind, :email) - notification.update!(email_processed_at: Time.current) - return - end - - sender = notification.actor || notification.unit.main_convenor_user - if sender.nil? - notification.update!(email_processed_at: Time.current) - return - end - - mail = - if notification.kind == 'discuss_warning' - NotificationsMailer.discussion_deadline_approaching( - notification.task, - sender, - notification.discuss_deadline || notification.unit.discuss_timeout_expiry_date(notification.task) - ) - else - NotificationsMailer.discussion_deadline_missed(notification.task, sender) - end - - mail.deliver_now - notification.update!( - email_processed_at: Time.current, - email_sent_at: Time.current - ) - end -end diff --git a/app/sidekiq/send_discuss_timeout_email_job.rb b/app/sidekiq/send_discuss_timeout_email_job.rb new file mode 100644 index 0000000000..e47d0a5327 --- /dev/null +++ b/app/sidekiq/send_discuss_timeout_email_job.rb @@ -0,0 +1,26 @@ +class SendDiscussTimeoutEmailJob + include Sidekiq::Job + + sidekiq_options retry: 5 + + def perform(task_id, sender_id, notification_type, expiry_date = nil) + task = Task.find_by(id: task_id) + sender = User.find_by(id: sender_id) + return if task.blank? || sender.blank? + return unless task.unit.send_notifications + + kind = notification_type == 'approaching' ? 'discuss_warning' : 'discuss_expired' + return unless NotificationSetting.for(task.project.student).delivers?(task.unit, kind, :email) + + mail = case notification_type + when 'approaching' + NotificationsMailer.discussion_deadline_approaching(task, sender, Date.iso8601(expiry_date)) + when 'missed' + NotificationsMailer.discussion_deadline_missed(task, sender) + else + raise ArgumentError, "Unknown discussion deadline notification type: #{notification_type}" + end + + mail.deliver_now + end +end diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index c6f22701b2..46ebce81b7 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -17,7 +17,6 @@ def perform(setting_id) .received_notifications .email_pending .email_ready(now) - .where.not(kind: Notification::DISCUSS_KINDS) .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) .to_a deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification) } diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 8c35edf9d7..5a586485be 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -110,22 +110,22 @@ def test_discuss_timeout_notifications_send_emails task.update!(moved_to_discuss_at: 8.days.ago) assert_equal 1, unit.notify_discuss_timeouts! - assert_equal 1, SendDiscussDeadlineEmailJob.jobs.count + assert_equal 1, SendDiscussTimeoutEmailJob.jobs.count - approaching_job = SendDiscussDeadlineEmailJob.jobs.shift + approaching_job = SendDiscussTimeoutEmailJob.jobs.shift assert_emails 1 do - SendDiscussDeadlineEmailJob.new.perform(*approaching_job['args']) + SendDiscussTimeoutEmailJob.new.perform(*approaching_job['args']) end assert_includes ActionMailer::Base.deliveries.last.subject, 'Discussion deadline approaching' task.update!(moved_to_discuss_at: 15.days.ago) assert_equal 1, unit.notify_discuss_timeouts! - assert_equal 1, SendDiscussDeadlineEmailJob.jobs.count + assert_equal 1, SendDiscussTimeoutEmailJob.jobs.count - missed_job = SendDiscussDeadlineEmailJob.jobs.shift + missed_job = SendDiscussTimeoutEmailJob.jobs.shift assert_emails 1 do - SendDiscussDeadlineEmailJob.new.perform(*missed_job['args']) + SendDiscussTimeoutEmailJob.new.perform(*missed_job['args']) end assert_includes ActionMailer::Base.deliveries.last.subject, 'Discussion deadline missed' end @@ -147,7 +147,28 @@ def test_discuss_timeout_notifications_are_not_queued_for_unenrolled_students assert_equal 1, unit.notify_discuss_timeouts! assert_empty Notification.where(recipient: project.student, task: task) - assert_empty SendDiscussDeadlineEmailJob.jobs + assert_empty SendDiscussTimeoutEmailJob.jobs + end + + def test_discuss_timeout_notification_is_created_when_immediate_email_is_off + unit = FactoryBot.create( + :unit, + discuss_timeout_enabled: true, + discuss_timeout_warning_days: 7, + discuss_timeout_expire_days: 14 + ) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + task.update!(task_status: TaskStatus.discuss, moved_to_discuss_at: 8.days.ago) + settings = NotificationSetting.for(project.student) + settings.update!(channels: settings.channels.merge('discuss_warning' => ['in_app'])) + + assert_equal 1, unit.notify_discuss_timeouts! + + notification = Notification.find_by!(recipient: project.student, task: task, kind: 'discuss_warning') + assert_nil notification.read_at + assert_not_nil notification.email_processed_at + assert_empty SendDiscussTimeoutEmailJob.jobs end def test_batch_feedback_updates_unenrolled_students_without_emailing_them diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index b4634572aa..0fd7d0f197 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -104,9 +104,8 @@ def test_switching_the_digest_off_processes_notifications_waiting_for_it settings.update!(digest_frequency: 'off') assert_not_nil digested.reload.email_processed_at + assert_not_nil alert.reload.email_processed_at assert_nil settings.reload.next_digest_at - # Alerts never went through the digest, so they are still waiting to send. - assert_nil alert.reload.email_processed_at end def test_unknown_kinds_and_channels_are_rejected diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index 74180f8d60..f7e1667f18 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -80,16 +80,16 @@ def test_an_unread_overseer_failure_is_carried_by_the_digest assert_not_nil notification.reload.email_sent_at end - def test_discussion_deadlines_are_left_for_their_own_email + def test_an_unread_discussion_deadline_is_carried_by_the_digest settings = create_settings notification = FactoryBot.create(:notification, recipient: settings.user, kind: 'discuss_warning') - assert_no_emails do + assert_emails 1 do SendNotificationDigestJob.new.perform(settings.id) end - assert_nil notification.reload.email_processed_at - assert_nil notification.email_sent_at + assert_not_nil notification.reload.email_processed_at + assert_not_nil notification.email_sent_at end def test_unit_email_master_switch_processes_without_sending From 27b169b03a9be7265868cbf7668e6bb78f47d818 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:46:52 +1000 Subject: [PATCH 22/57] fix: test --- test/api/comments/status_test.rb | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/test/api/comments/status_test.rb b/test/api/comments/status_test.rb index cc0ce28c13..49d2164736 100644 --- a/test/api/comments/status_test.rb +++ b/test/api/comments/status_test.rb @@ -10,9 +10,18 @@ def app end def test_status_comments - project = Project.first + unit = FactoryBot.create( + :unit, + task_count: 0, + student_count: 1, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + allow_flexible_dates: false, + mark_late_submissions_as_assess_in_portfolio: false + ) + project = unit.active_projects.first user = project.student - unit = project.unit td = TaskDefinition.new({ unit_id: unit.id, @@ -55,22 +64,14 @@ def test_status_comments rff_comment = task.comments.where(task_status_id: TaskStatus.ready_for_feedback.id).first te_comment = task.comments.where(task_status_id: TaskStatus.time_exceeded.id).first - # Status comments are attention_audience :none, so they never sit unread in the - # comment inbox for anyone - they raise a notification instead. - assert rff_comment.attention_none?, 'Error: RFF status comment should not require attention' - assert te_comment.attention_none?, 'Error: TE status comment should not require attention' - + # Task status generated by students is marked read by staff assert rff_comment.read_by?(user), 'Error: RFF status comment should be read by the student' - assert rff_comment.read_by?(tutor), 'Error: RFF status comment should be read by the tutor' + assert rff_comment.read_by?(tutor), 'Error: TE status comment should be read by the tutor' + # Task status comments by staff is not marked read by students assert te_comment.read_by?(tutor), 'Error: TE status comment should be read by the tutor' assert te_comment.read_by?(user), 'Error: TE status comment should be read by the student' - # The student has not opened the task, so the staff status change is unseen and notified. - assert_not te_comment.seen_by?(user), 'Error: TE status comment should not be seen by the student' - assert Notification.exists?(source: te_comment, recipient: user, kind: 'task_status_changed'), - 'Error: TE status comment should notify the student' - td.destroy! end From 5cbaa8718f22dc8872840afff9de97d0246710c3 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:47:02 +1000 Subject: [PATCH 23/57] fix: test --- test/models/notification_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index c010ea3165..45b0ddbeef 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -77,7 +77,7 @@ def test_group_builder_merges_mixed_task_activity assert_equal 1, groups.count assert_equal 3, groups.first[:counts]['new_task_comment'] - assert_equal 'fix_and_resubmit', groups.first[:latest_status] + assert_equal :fix_and_resubmit, groups.first[:latest_status] assert_includes groups.first[:summary], '3 new comments' assert_includes groups.first[:summary], 'task status changed to Fix and Resubmit' end From ed7e15b52a526954fe58772dc7c3a3b0f7fd1c22 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:24 +1000 Subject: [PATCH 24/57] chore: fix misleading method --- app/services/notification_group_builder.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index 682f0bb9ed..0441be302f 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -15,7 +15,7 @@ def groups @notifications .group_by { |notification| grouping_key(notification) } .values - .map { |items| serialize(items) } + .map { |items| build_group(items) } .sort_by { |group| [group[:read] ? 1 : 0, SEVERITY_ORDER.fetch(group[:severity]), -group[:latest_at].to_f] } end @@ -31,7 +31,7 @@ def grouping_key(notification) "#{state_key}:unit:#{notification.unit_id}:#{notification.kind}" end - def serialize(items) + def build_group(items) latest = items.max_by(&:created_at) task = latest.task counts = items.each_with_object(Hash.new(0)) { |notification, result| result[notification.kind] += 1 } @@ -43,6 +43,7 @@ def serialize(items) tutor_notes = items .select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } .sort_by { |notification| [notification.created_at, notification.id] } + detail = detail_for(counts, latest_status) { key: grouping_key(latest), @@ -65,8 +66,8 @@ def serialize(items) tutor_note_ids: tutor_notes.filter_map(&:tutor_note_id), tutor_note_unit_role_id: tutor_notes.first&.unit_role_id, overseer_assessment_id: overseer_assessment_id(items), - detail: detail_for(counts, latest_status), - summary: "#{subject_for(task, latest.recipient, counts)} - #{detail_for(counts, latest_status)}" + detail: detail, + summary: "#{subject_for(task, latest.recipient, counts)} - #{detail}" } end From a6cac39aae12d38b0d90d577560ddff5084ee3a6 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:13:50 +1000 Subject: [PATCH 25/57] refactor: use task definition for heading and notification details as subtitle --- .../notification_digest.html.erb | 12 +++++++++++- .../notification_digest.text.erb | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index da4454b6e5..eec6fd4330 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -60,6 +60,9 @@ .notification-box p { margin: 8px 0 0; } + .notification-detail { + margin-top: 2px; + } @@ -78,7 +81,14 @@ <% section[:groups].each do |group| %>
- <%= group[:summary] %> + <% if group[:task] %> + <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> + <% elsif group[:project_id] %> + Portfolio + <% else %> + Unit notification + <% end %> +
<%= group[:detail] %>
<% if group[:task] %>

diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index d6ea11ad14..1e92863a91 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -4,7 +4,8 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <% @units.each do |section| %> <%= section[:unit].code %> - <%= section[:unit].name %> <% section[:groups].each do |group| %> -- <%= group[:summary] %> +- <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %> + <%= group[:detail] %> <% if group[:task] %> <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> <% elsif group[:project_id] %> From 2dc993563197170e240325c41457d5e210a7d2d4 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:58:10 +1000 Subject: [PATCH 26/57] chore: fix strip --- app/models/overseer_assessment.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb index e651153684..4b8a49213a 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -123,7 +123,7 @@ def latest_assessment_comment end def add_assessment_comment(text = 'Automated Assessment Started') - text.strip! + text = text.strip return nil if text.blank? tutor = project.tutor_for(task.task_definition) @@ -143,7 +143,7 @@ def add_assessment_comment(text = 'Automated Assessment Started') end def update_assessment_comment(text) - text.strip! + text = text.strip return nil if text.blank? assessment_comment = assessment_comments.last From ef12098703b5f50dcaea7e2deefad64e68218869 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:58:57 +1000 Subject: [PATCH 27/57] refactor: sort notifications by recently updated and show timestamps --- app/api/notifications_api.rb | 2 +- app/mailers/notifications_mailer.rb | 6 ++++++ app/services/notification_group_builder.rb | 10 +++------- app/sidekiq/send_notification_digest_job.rb | 2 +- .../notification_digest.html.erb | 6 ++++++ .../notification_digest.text.erb | 1 + test/mailers/unit_mail_test.rb | 3 ++- test/models/notification_test.rb | 20 ++++++++++--------- 8 files changed, 31 insertions(+), 19 deletions(-) diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 0b2f1e1221..55439caa62 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -17,7 +17,7 @@ def notification_settings def notification_scope current_user .received_notifications - .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) + .includes(:recipient, :unit, { project: :campus }, task: [:task_definition, { project: :user }]) end # Kinds the user has switched off in the app stay in the ledger for the diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 3b67d38bf0..e1a055c15d 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -103,6 +103,11 @@ def notification_digest(recipient, notifications) ) end + def notification_timestamp(group) + time = group[:latest_at].in_time_zone(group[:timezone]) + "#{time.day.ordinalize} #{time.strftime('%B %Y at %H:%M')}" + end + def discussion_deadline_approaching(task, sender, expiry_date) add_discussion_deadline_details(task, sender) @deadline = task.unit.formatted_discuss_timeout_date(expiry_date) @@ -156,6 +161,7 @@ def this_these(num) helper_method :were_was helper_method :are_is helper_method :this_these + helper_method :notification_timestamp private diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index 0441be302f..fb9b4d7647 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true class NotificationGroupBuilder - SEVERITY_ORDER = { - 'critical' => 0, - 'warning' => 1, - 'normal' => 2 - }.freeze - def initialize(notifications) @notifications = notifications.to_a end @@ -16,7 +10,7 @@ def groups .group_by { |notification| grouping_key(notification) } .values .map { |items| build_group(items) } - .sort_by { |group| [group[:read] ? 1 : 0, SEVERITY_ORDER.fetch(group[:severity]), -group[:latest_at].to_f] } + .sort_by { |group| -group[:latest_at].to_f } end private @@ -44,6 +38,7 @@ def build_group(items) .select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } .sort_by { |notification| [notification.created_at, notification.id] } detail = detail_for(counts, latest_status) + project = latest.project || task&.project { key: grouping_key(latest), @@ -63,6 +58,7 @@ def build_group(items) read: items.all? { |notification| notification.read_at.present? }, read_at: items.filter_map(&:read_at).max, latest_at: latest.created_at, + timezone: project&.campus&.timezone || Time.zone.name, tutor_note_ids: tutor_notes.filter_map(&:tutor_note_id), tutor_note_unit_role_id: tutor_notes.first&.unit_role_id, overseer_assessment_id: overseer_assessment_id(items), diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index 46ebce81b7..3be5c287f5 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -17,7 +17,7 @@ def perform(setting_id) .received_notifications .email_pending .email_ready(now) - .includes(:recipient, :unit, task: [:task_definition, { project: :user }]) + .includes(:recipient, :unit, { project: :campus }, task: [:task_definition, { project: :user }]) .to_a deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification) } diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index eec6fd4330..66b08a9296 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -63,6 +63,11 @@ .notification-detail { margin-top: 2px; } + .notification-time { + color: #777; + font-size: 0.75em; + margin: 0 0 4px; + } @@ -88,6 +93,7 @@ <% else %> Unit notification <% end %> +

<%= group[:detail] %>
<% if group[:task] %>

diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index 1e92863a91..4eabaa6db1 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -5,6 +5,7 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <%= section[:unit].code %> - <%= section[:unit].name %> <% section[:groups].each do |group| %> - <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %> + <%= notification_timestamp(group) %> <%= group[:detail] %> <% if group[:task] %> <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 5a586485be..161e678d8e 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -159,7 +159,8 @@ def test_discuss_timeout_notification_is_created_when_immediate_email_is_off ) project = unit.active_projects.first task = project.task_for_task_definition(unit.task_definitions.first) - task.update!(task_status: TaskStatus.discuss, moved_to_discuss_at: 8.days.ago) + task.update!(task_status: TaskStatus.discuss) + task.update!(moved_to_discuss_at: 8.days.ago) settings = NotificationSetting.for(project.student) settings.update!(channels: settings.channels.merge('discuss_warning' => ['in_app'])) diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 45b0ddbeef..4e1b14b9ac 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -175,7 +175,7 @@ def test_portfolio_result_uses_the_notification_ledger assert_nil notification.task assert_nil notification.read_at assert_equal @project.id, group[:project_id] - assert_equal 'Portfolio - portfolio ready to review', group[:summary] + assert_equal 'Portfolio - Portfolio ready to review', group[:summary] end def test_a_new_portfolio_result_resolves_the_previous_one @@ -206,25 +206,27 @@ def test_destroying_a_source_removes_its_notification assert_not Notification.exists?(notification.id) end - def test_unread_groups_sort_before_read_history_even_when_read_group_is_urgent - read_notification = FactoryBot.create( + def test_groups_are_sorted_by_newest_activity + older_unread_notification = FactoryBot.create( :notification, recipient: @student, unit: @unit, kind: 'discuss_expired', - read_at: Time.current + created_at: 2.hours.ago ) - unread_notification = FactoryBot.create( + newer_read_notification = FactoryBot.create( :notification, recipient: @student, unit: @unit, - kind: 'new_task_comment' + kind: 'new_task_comment', + created_at: 1.hour.ago, + read_at: Time.current ) - groups = NotificationGroupBuilder.new([read_notification, unread_notification]).groups + groups = NotificationGroupBuilder.new([older_unread_notification, newer_read_notification]).groups - assert_equal unread_notification.id, groups.first[:notification_ids].first - assert_not groups.first[:read] + assert_equal newer_read_notification.id, groups.first[:notification_ids].first + assert groups.first[:read] end def test_marking_task_read_processes_email_and_marking_source_unread_reopens_in_app_only From 7d047cca1a84577e03782d946dc1d0f34e13a256 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:46:05 +1000 Subject: [PATCH 28/57] feat: display author name of tutor notes in notification --- app/api/notifications_api.rb | 2 +- app/services/notification_group_builder.rb | 13 +++++++++---- app/sidekiq/send_notification_digest_job.rb | 2 +- test/models/notification_test.rb | 1 + 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 55439caa62..35627226ef 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -17,7 +17,7 @@ def notification_settings def notification_scope current_user .received_notifications - .includes(:recipient, :unit, { project: :campus }, task: [:task_definition, { project: :user }]) + .includes(:recipient, :actor, :unit, { project: :campus }, task: [:task_definition, { project: :user }]) end # Kinds the user has switched off in the app stay in the ledger for the diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index fb9b4d7647..c3b1979498 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -37,7 +37,7 @@ def build_group(items) tutor_notes = items .select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } .sort_by { |notification| [notification.created_at, notification.id] } - detail = detail_for(counts, latest_status) + detail = detail_for(items, counts, latest_status) project = latest.project || task&.project { @@ -108,7 +108,7 @@ def subject_for(task, recipient, counts) # What happened, without the task it happened to, so callers can show the two # separately rather than splitting the summary back apart. - def detail_for(counts, latest_status) + def detail_for(items, counts, latest_status) details = [] details << 'discussion deadline missed' if counts['discuss_expired'].positive? details << 'discussion deadline approaching' if counts['discuss_warning'].positive? @@ -116,8 +116,13 @@ def detail_for(counts, latest_status) details << 'overseer assessment failed' if counts['overseer_failed'].positive? details << 'portfolio compilation failed' if counts['portfolio_failed'].positive? details << 'portfolio ready to review' if counts['portfolio_ready'].positive? - moderation_notes = Notification::MODERATION_KINDS.sum { |kind| counts[kind] } - details << pluralize(moderation_notes, 'moderation note') if moderation_notes.positive? + moderation_notifications = items.select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } + if moderation_notifications.any? + staff_names = moderation_notifications.filter_map { |notification| notification.actor&.name }.uniq + moderation_detail = pluralize(moderation_notifications.count, 'moderation note') + moderation_detail += " from #{staff_names.to_sentence}" if staff_names.any? + details << moderation_detail + end details << pluralize(counts['new_task_comment'], 'new comment') if counts['new_task_comment'].positive? details << "task status changed to #{status_name(latest_status)}" if latest_status.present? diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index 3be5c287f5..6f59ddc69a 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -17,7 +17,7 @@ def perform(setting_id) .received_notifications .email_pending .email_ready(now) - .includes(:recipient, :unit, { project: :campus }, task: [:task_definition, { project: :user }]) + .includes(:recipient, :actor, :unit, { project: :campus }, task: [:task_definition, { project: :user }]) .to_a deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification) } diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 4e1b14b9ac..46816d1637 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -121,6 +121,7 @@ def test_task_tutor_note_and_student_feedback_share_a_group_with_two_actions assert_equal [Notification.find_by!(tutor_note: tutor_note).id], group[:tutor_note_notification_ids] assert group.dig(:task, :staff_view) assert_equal @student.name, group.dig(:task, :student_name) + assert_includes group[:detail], "1 moderation note from #{@unit.main_convenor_user.name}" end def test_duplicate_source_event_is_deduplicated_per_recipient From 21b142073b9548186151390eef52b2b0522c145b Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:51:00 +1000 Subject: [PATCH 29/57] test: ensure multiple status changes only shows most recent --- test/models/notification_test.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 46816d1637..82fd97f860 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -72,14 +72,16 @@ def test_only_latest_staff_status_notification_remains_unread def test_group_builder_merges_mixed_task_activity 3.times { |number| @task.add_text_comment(@tutor, "Feedback #{number}") } @task.add_status_comment(@tutor, TaskStatus.fix_and_resubmit) + @task.add_status_comment(@tutor, TaskStatus.complete) groups = NotificationGroupBuilder.new(Notification.where(recipient: @student).unread).groups assert_equal 1, groups.count assert_equal 3, groups.first[:counts]['new_task_comment'] - assert_equal :fix_and_resubmit, groups.first[:latest_status] + assert_equal 1, groups.first[:counts]['task_status_changed'] + assert_equal :complete, groups.first[:latest_status] assert_includes groups.first[:summary], '3 new comments' - assert_includes groups.first[:summary], 'task status changed to Fix and Resubmit' + assert_includes groups.first[:summary], 'task status changed to Complete' end def test_discuss_expiry_supersedes_warning_without_hiding_feedback From b84872fce22c6234f93d33497fba73b350084baa Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:10:08 +1000 Subject: [PATCH 30/57] feat: create notifications for communication emails --- app/api/notifications_api.rb | 10 ++- app/models/notification.rb | 33 +++++++++- app/models/notification_setting.rb | 5 +- app/services/notification_group_builder.rb | 12 +++- app/sidekiq/execute_communication_set_job.rb | 30 +++++++++ ...45_create_notifications_and_preferences.rb | 4 ++ db/schema.rb | 2 + test/api/notifications_api_test.rb | 23 +++++++ test/models/notification_setting_test.rb | 1 + test/models/notification_test.rb | 35 ++++++++++ .../execute_communication_set_job_test.rb | 64 ++++++++++++++++++- 11 files changed, 209 insertions(+), 10 deletions(-) diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 35627226ef..9381b33a48 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -27,10 +27,12 @@ def shown_in_app(notifications) end def unread_group_count - rows = current_user.received_notifications.unread.pluck(:task_id, :project_id, :unit_id, :kind, :unit_role_id) - rows.select { |_task_id, _project_id, unit_id, kind, _unit_role_id| notification_settings.shows_in_app?(unit_id, kind) }.map do |task_id, project_id, unit_id, kind, unit_role_id| + rows = current_user.received_notifications.unread.pluck(:id, :task_id, :project_id, :unit_id, :kind, :unit_role_id) + rows.select { |_id, _task_id, _project_id, unit_id, kind, _unit_role_id| notification_settings.shows_in_app?(unit_id, kind) }.map do |id, task_id, project_id, unit_id, kind, unit_role_id| if task_id.present? "task:#{task_id}" + elsif Notification::COMMUNICATION_KINDS.include?(kind) + "communication-email:#{id}" elsif Notification::PORTFOLIO_KINDS.include?(kind) "portfolio:#{project_id}" elsif Notification::MODERATION_KINDS.include?(kind) @@ -118,7 +120,9 @@ def accessible_unit_ids group.dig(:unit, :name), group.dig(:task, :abbreviation), group.dig(:task, :name), - group.dig(:task, :student_name) + group.dig(:task, :student_name), + group[:message_subject], + group[:message_body] ].compact.any? { |value| value.to_s.downcase.include?(query) } end end diff --git a/app/models/notification.rb b/app/models/notification.rb index 5e78537732..23d297d55c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -13,12 +13,14 @@ class Notification < ApplicationRecord moderation_note_from_mentee portfolio_ready portfolio_failed + communication_email ].freeze CHANNELS = %w[in_app email push].freeze MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze PORTFOLIO_KINDS = %w[portfolio_ready portfolio_failed].freeze + COMMUNICATION_KINDS = %w[communication_email].freeze belongs_to :recipient, class_name: 'User', inverse_of: :received_notifications belongs_to :unit @@ -154,6 +156,31 @@ def self.create_for_portfolio(project, success:) notification end + # The communications system has already delivered this message as an email. + # Record an in-app copy without passing it through the notification email + # channel, which would send the recipient the same message twice. + def self.create_for_communication_email( + recipient:, + unit:, + project:, + actor:, + subject:, + body:, + deduplication_key: + ) + create_event( + recipient: recipient, + unit: unit, + project: project, + actor: actor, + kind: 'communication_email', + message_subject: subject, + message_body: body, + deduplication_key: deduplication_key, + channels: ['in_app'] + ) + end + def self.create_event(**attributes) recipient = attributes.fetch(:recipient) unit = attributes.fetch(:unit) @@ -162,7 +189,7 @@ def self.create_event(**attributes) return if withdrawn_student?(attributes[:project], recipient) settings = NotificationSetting.for(recipient) - channels = settings.channels_for_unit_id(unit.id, kind) + channels = attributes[:channels] || settings.channels_for_unit_id(unit.id, kind) return if channels.empty? notification = find_or_initialize_by( @@ -183,7 +210,9 @@ def self.create_event(**attributes) task_status: attributes[:task_status], unit_role: attributes[:unit_role], discuss_deadline: attributes[:discuss_deadline], - email_not_before: attributes[:email_not_before] + email_not_before: attributes[:email_not_before], + message_subject: attributes[:message_subject], + message_body: attributes[:message_body] ) notification.save! diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index c57f0833fd..982c8c8cd7 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -28,7 +28,9 @@ def self.for(user) end def self.default_channels - Notification::KINDS.index_with { %w[in_app email] } + Notification::KINDS.index_with do |kind| + Notification::COMMUNICATION_KINDS.include?(kind) ? ['in_app'] : %w[in_app email] + end end # The channels a unit delivers a kind on, honouring its override when it has @@ -36,6 +38,7 @@ def self.default_channels def channels_for_unit_id(unit_id, kind) override = override_for(unit_id) return [] if override&.muted + return ['in_app'] if Notification::COMMUNICATION_KINDS.include?(kind.to_s) Array((override&.customised? ? override.channels : channels)[kind.to_s]) end diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index c3b1979498..8f4f2d5233 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -17,6 +17,7 @@ def groups def grouping_key(notification) state_key = notification.read_at ? "read:#{notification.read_at.to_f}" : 'unread' + return "#{state_key}:communication-email:#{notification.id}" if notification.kind == 'communication_email' return "#{state_key}:task:#{notification.task_id}" if notification.task_id.present? return "#{state_key}:portfolio:#{notification.project_id}" if Notification::PORTFOLIO_KINDS.include?(notification.kind) @@ -62,8 +63,10 @@ def build_group(items) tutor_note_ids: tutor_notes.filter_map(&:tutor_note_id), tutor_note_unit_role_id: tutor_notes.first&.unit_role_id, overseer_assessment_id: overseer_assessment_id(items), + message_subject: latest.message_subject, + message_body: latest.message_body, detail: detail, - summary: "#{subject_for(task, latest.recipient, counts)} - #{detail}" + summary: "#{subject_for(task, latest.recipient, counts, latest)} - #{detail}" } end @@ -97,7 +100,8 @@ def overseer_assessment_id(items) items.select { |notification| notification.kind == 'overseer_failed' }.max_by(&:created_at)&.overseer_assessment_id end - def subject_for(task, recipient, counts) + def subject_for(task, recipient, counts, latest) + return latest.message_subject.presence || 'Email message' if counts['communication_email'].positive? return 'Portfolio' if Notification::PORTFOLIO_KINDS.any? { |kind| counts[kind].positive? } return 'Unit notification' if task.nil? @@ -110,6 +114,10 @@ def subject_for(task, recipient, counts) # separately rather than splitting the summary back apart. def detail_for(items, counts, latest_status) details = [] + if counts['communication_email'].positive? + sender = items.max_by(&:created_at).actor&.name + details << (sender.present? ? "Message from #{sender}" : 'Message') + end details << 'discussion deadline missed' if counts['discuss_expired'].positive? details << 'discussion deadline approaching' if counts['discuss_warning'].positive? details << 'submission PDF generation failed' if counts['pdf_generation_failed'].positive? diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb index e645cb698f..580b3ee7fb 100644 --- a/app/sidekiq/execute_communication_set_job.rb +++ b/app/sidekiq/execute_communication_set_job.rb @@ -1,4 +1,5 @@ require 'csv' +require 'securerandom' class ExecuteCommunicationSetJob include Sidekiq::Job @@ -170,6 +171,8 @@ def execute_email_student_action(action, projects, unit, rule) rule: rule ).deliver_now + create_email_notification(action, project, recipient, unit, subject, body) + { action_id: action.id, action_type: action.type, @@ -211,6 +214,8 @@ def execute_email_staff_action(action, projects, unit, rule) rule: rule ).deliver_now + create_email_notification(action, project, recipient, unit, subject, body) + { action_id: action.id, action_type: action.type, @@ -364,6 +369,31 @@ def staff_recipients_for(project, unit, action) recipients.select { |recipient| recipient&.email.present? }.uniq(&:id) end + def create_email_notification(action, project, recipient, unit, subject, body) + Notification.create_for_communication_email( + recipient: recipient, + unit: unit, + project: project, + actor: sender_user_for(unit), + subject: subject, + body: body, + deduplication_key: [ + 'communication-email', + execution_identifier, + action.id, + project.id, + recipient.id + ].join(':') + ) + end + + # Sidekiq keeps a jid across retries, so a retry cannot create a second + # notification for the same delivered action. Direct test/job invocation has + # no jid and receives a per-run identifier instead. + def execution_identifier + @execution_identifier ||= (respond_to?(:jid) ? jid : nil).presence || SecureRandom.uuid + end + def render_template(template, project, unit, rule, affected_students_count, target_grade_override = nil, action_results = []) return '' if template.blank? diff --git a/db/migrate/20260825000645_create_notifications_and_preferences.rb b/db/migrate/20260825000645_create_notifications_and_preferences.rb index 5ae18d967f..d75de27a39 100644 --- a/db/migrate/20260825000645_create_notifications_and_preferences.rb +++ b/db/migrate/20260825000645_create_notifications_and_preferences.rb @@ -11,6 +11,10 @@ def change # Identifies the event, so cron re-runs and job retries cannot raise it twice. t.string :deduplication_key, null: false, limit: 191 + # Rendered content for messages sent by the communications system. + t.string :message_subject + t.text :message_body + # What raised the notification t.references :task_comment, null: true t.references :overseer_assessment, null: true, index: false diff --git a/db/schema.rb b/db/schema.rb index a218f8feb2..9383f92f19 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -408,6 +408,8 @@ t.bigint "actor_id" t.string "kind", limit: 64, null: false t.string "deduplication_key", limit: 191, null: false + t.string "message_subject" + t.text "message_body" t.bigint "task_comment_id" t.bigint "overseer_assessment_id" t.bigint "tutor_note_id" diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index 1127250f05..574f594d8a 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -54,6 +54,29 @@ def test_portfolio_notifications_are_grouped_by_project assert_equal({ 'portfolio_ready' => 1 }, last_response_body.dig('groups', 0, 'counts')) end + def test_communication_emails_are_returned_individually_with_their_full_content + @student.received_notifications.destroy_all + 2.times do |index| + Notification.create_for_communication_email( + recipient: @student, + unit: @unit, + project: @project, + actor: @tutor, + subject: "Update #{index}", + body: "Full email body #{index}", + deduplication_key: "communication-email:api-test:#{index}" + ) + end + + get '/api/notifications', state: 'unread' + + assert_equal 200, last_response.status + assert_equal 2, last_response_body['unread_count'] + assert_equal 2, last_response_body['groups'].count + assert_equal ['Update 0', 'Update 1'], last_response_body['groups'].pluck('message_subject').sort + assert_equal ['Full email body 0', 'Full email body 1'], last_response_body['groups'].pluck('message_body').sort + end + def test_get_filters_groups_by_category_and_search get '/api/notifications', state: 'unread', diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 0fd7d0f197..5a14334faf 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -9,6 +9,7 @@ def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push assert_equal 1, settings.digest_weekday assert settings.weekly_summary assert_equal %w[in_app email], settings.channels['new_task_comment'] + assert_equal ['in_app'], settings.channels['communication_email'] assert_equal Notification::KINDS.sort, settings.channels.keys.sort end diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 82fd97f860..88493162b3 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -181,6 +181,41 @@ def test_portfolio_result_uses_the_notification_ledger assert_equal 'Portfolio - Portfolio ready to review', group[:summary] end + def test_communication_email_keeps_its_rendered_content_as_an_individual_in_app_notification + notification = Notification.create_for_communication_email( + recipient: @student, + unit: @unit, + project: @project, + actor: @tutor, + subject: 'A personalised update', + body: "Hello #{@student.first_name},\n\nHere is the full message.", + deduplication_key: 'communication-email:test-run:1' + ) + group = NotificationGroupBuilder.new([notification]).groups.first + + assert_equal 'communication_email', notification.kind + assert_equal 'A personalised update', group[:message_subject] + assert_equal "Hello #{@student.first_name},\n\nHere is the full message.", group[:message_body] + assert_equal "A personalised update - Message from #{@tutor.name}", group[:summary] + assert_not_nil notification.email_processed_at + end + + def test_communication_emails_are_not_grouped_together + notifications = 2.times.map do |index| + Notification.create_for_communication_email( + recipient: @student, + unit: @unit, + project: @project, + actor: @tutor, + subject: "Message #{index}", + body: "Body #{index}", + deduplication_key: "communication-email:test-run:#{index}" + ) + end + + assert_equal 2, NotificationGroupBuilder.new(notifications).groups.count + end + def test_a_new_portfolio_result_resolves_the_previous_one ready = Notification.create_for_portfolio(@project, success: true) @project.update!(updated_at: 1.second.from_now) diff --git a/test/sidekiq/execute_communication_set_job_test.rb b/test/sidekiq/execute_communication_set_job_test.rb index 8ec1afb8b3..656073c8a0 100644 --- a/test/sidekiq/execute_communication_set_job_test.rb +++ b/test/sidekiq/execute_communication_set_job_test.rb @@ -3,6 +3,66 @@ require 'test_helper' class ExecuteCommunicationSetJobTest < ActiveSupport::TestCase + def test_email_student_action_creates_a_notification_with_the_rendered_email + unit = FactoryBot.create( + :unit, + student_count: 1, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + task_count: 0 + ) + project = unit.active_projects.first + student = project.student + communication_set = unit.communication_sets.create!(name: 'Student email set', active: true) + rule = communication_set.communication_rules.create!(name: 'Welcome', operator: 'and', position: 0) + rule.communication_actions.create!( + type: 'EmailStudentAction', + subject: 'Hello {{student.first_name}}', + body: "Welcome to {{unit.code}}.\nThis is your full message." + ) + + assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do + ExecuteCommunicationSetJob.new.perform(communication_set.id) + end + + notification = Notification.find_by!(recipient: student, project: project, kind: 'communication_email') + assert_equal "Hello #{student.first_name}", notification.message_subject + assert_equal "Welcome to #{unit.code}.\nThis is your full message.", notification.message_body + assert_nil notification.read_at + assert_not_nil notification.email_processed_at + end + + def test_email_staff_action_creates_a_notification_for_the_staff_recipient + unit = FactoryBot.create( + :unit, + student_count: 1, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + task_count: 1 + ) + project = unit.active_projects.first + tutor = project.tutor_for(unit.task_definitions.first) + communication_set = unit.communication_sets.create!(name: 'Staff email set', active: true) + rule = communication_set.communication_rules.create!(name: 'Follow up', operator: 'and', position: 0) + rule.communication_actions.create!( + type: 'EmailStaffAction', + subject: 'Follow up with {{student.full_name}}', + body: 'Please contact {{student.username}}.', + email_tutors: true, + email_convenors: false + ) + + assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do + ExecuteCommunicationSetJob.new.perform(communication_set.id) + end + + notification = Notification.find_by!(recipient: tutor, project: project, kind: 'communication_email') + assert_equal "Follow up with #{project.student.name}", notification.message_subject + assert_equal "Please contact #{project.student.username}.", notification.message_body + end + def test_task_comment_action_adds_a_comment_to_each_selected_students_task unit = FactoryBot.create( :unit, @@ -51,7 +111,7 @@ def test_task_comment_action_adds_a_comment_to_each_selected_students_task assert_equal comment_author, comment_one.user assert_equal comment_author, comment_two.user - assert_equal 'Please review Ada for ' + unit.code, comment_one.comment - assert_equal 'Please review Grace for ' + unit.code, comment_two.comment + assert_equal "Please review Ada for #{unit.code}", comment_one.comment + assert_equal "Please review Grace for #{unit.code}", comment_two.comment end end From 6c5254d87ca3083d70cfe85b43d5aac7c44a1785 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:52:13 +1000 Subject: [PATCH 31/57] feat: enable option for hourly and custom intervals --- .../entities/notification_setting_entity.rb | 18 ++++++ .../notification_unit_override_entity.rb | 7 +++ app/api/notifications_api.rb | 28 ++------- app/models/notification_setting.rb | 62 +++++++++++++++++-- ...45_create_notifications_and_preferences.rb | 4 ++ db/schema.rb | 3 + test/api/notifications_api_test.rb | 14 +++++ test/factories/notifications.rb | 3 + test/models/notification_setting_test.rb | 58 ++++++++++++++++- 9 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 app/api/entities/notification_setting_entity.rb create mode 100644 app/api/entities/notification_unit_override_entity.rb diff --git a/app/api/entities/notification_setting_entity.rb b/app/api/entities/notification_setting_entity.rb new file mode 100644 index 0000000000..8cdabee813 --- /dev/null +++ b/app/api/entities/notification_setting_entity.rb @@ -0,0 +1,18 @@ +module Entities + class NotificationSettingEntity < Grape::Entity + expose :id + expose :channels + expose :digest_frequency + expose :digest_interval_hours + expose :digest_start_time + expose :digest_time + expose :digest_timezone, &:resolved_digest_timezone + expose :digest_weekday + expose :weekly_summary + expose :next_digest_at + expose :last_digest_at + expose :units, using: NotificationUnitOverrideEntity do |settings| + settings.user.notification_unit_overrides.order(:unit_id) + end + end +end diff --git a/app/api/entities/notification_unit_override_entity.rb b/app/api/entities/notification_unit_override_entity.rb new file mode 100644 index 0000000000..9a19e559ad --- /dev/null +++ b/app/api/entities/notification_unit_override_entity.rb @@ -0,0 +1,7 @@ +module Entities + class NotificationUnitOverrideEntity < Grape::Entity + expose :unit_id + expose :muted + expose :channels + end +end diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 9381b33a48..2afa56ff7b 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -43,28 +43,6 @@ def unread_group_count end.uniq.count end - def serialize_settings(settings) - { - id: settings.id, - channels: settings.channels, - digest_frequency: settings.digest_frequency, - digest_time: settings.digest_time, - digest_weekday: settings.digest_weekday, - weekly_summary: settings.weekly_summary, - next_digest_at: settings.next_digest_at, - last_digest_at: settings.last_digest_at, - units: current_user.notification_unit_overrides.order(:unit_id).map { |override| serialize_override(override) } - } - end - - def serialize_override(override) - { - unit_id: override.unit_id, - muted: override.muted, - channels: override.channels - } - end - # The units sent are the whole set that departs from the settings, so any unit # missing from it has been reset and no longer needs an override. def replace_unit_overrides(units) @@ -170,13 +148,15 @@ def accessible_unit_ids desc 'Get the notification settings for the current user' get '/notification_settings' do - serialize_settings(NotificationSetting.for(current_user)) + present NotificationSetting.for(current_user), with: Entities::NotificationSettingEntity end desc 'Update the notification settings for the current user' params do optional :channels, type: Hash optional :digest_frequency, type: String, values: NotificationSetting::FREQUENCIES + optional :digest_interval_hours, type: Integer, values: NotificationSetting::DIGEST_INTERVAL_HOURS + optional :digest_start_time, type: String optional :digest_time, type: String optional :digest_weekday, type: Integer optional :weekly_summary, type: Boolean @@ -195,6 +175,6 @@ def accessible_unit_ids replace_unit_overrides(changes[:units]) if changes.key?(:units) end - serialize_settings(settings) + present settings, with: Entities::NotificationSettingEntity end end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 982c8c8cd7..f0ebadd826 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -4,6 +4,7 @@ # channel defaults every unit follows until it is customised. class NotificationSetting < ApplicationRecord FREQUENCIES = %w[off hourly daily weekly].freeze + DIGEST_INTERVAL_HOURS = [1, 2, 3, 4, 6, 12].freeze TIME_FORMAT = /\A(?:[01]\d|2[0-3]):[0-5]\d\z/ attribute :channels, :json @@ -12,8 +13,11 @@ class NotificationSetting < ApplicationRecord validates :channels, notification_channels: true validates :digest_frequency, inclusion: { in: FREQUENCIES } + validates :digest_interval_hours, inclusion: { in: DIGEST_INTERVAL_HOURS } + validates :digest_start_time, format: { with: TIME_FORMAT } validates :digest_time, format: { with: TIME_FORMAT } validates :digest_weekday, inclusion: { in: 1..7 } + validate :digest_timezone_supported before_validation :apply_defaults before_save :refresh_next_digest_at, if: :schedule_changed? @@ -33,6 +37,19 @@ def self.default_channels end end + def self.default_digest_timezone + configured_timezone = ENV['TZ'].presence + return configured_timezone if ActiveSupport::TimeZone[configured_timezone].present? + + Time.zone.tzinfo.name + end + + # Use the first unit the user enrolled in as their home campus for now. + def resolved_digest_timezone + first_project = user.projects.where(enrolled: true).order(:id).includes(:campus).first + first_project&.campus&.timezone.presence || self.class.default_digest_timezone + end + # The channels a unit delivers a kind on, honouring its override when it has # one. A muted unit delivers nothing. def channels_for_unit_id(unit_id, kind) @@ -66,14 +83,19 @@ def advance_digest!(from: Time.current) def next_occurrence(from = Time.current) return nil if digest_frequency == 'off' - return from.beginning_of_hour + 1.hour if digest_frequency == 'hourly' + + local_from = from.in_time_zone(digest_time_zone) + if digest_frequency == 'hourly' + start_hour, start_minute = digest_start_time.split(':').map(&:to_i) + return next_hourly_occurrence(local_from, start_hour, start_minute) + end hour, minute = digest_time.split(':').map(&:to_i) - date = from.to_date + date = local_from.to_date date += (digest_weekday - date.cwday) % 7 if digest_frequency == 'weekly' - candidate = Time.zone.local(date.year, date.month, date.day, hour, minute) - candidate += digest_frequency == 'weekly' ? 1.week : 1.day if candidate <= from + candidate = digest_time_zone.local(date.year, date.month, date.day, hour, minute) + candidate += digest_frequency == 'weekly' ? 1.week : 1.day if candidate <= local_from candidate end @@ -88,6 +110,7 @@ def override_for(unit_id) def apply_defaults self.channels = self.class.default_channels if channels.nil? + self.digest_timezone = resolved_digest_timezone end def refresh_next_digest_at @@ -97,7 +120,10 @@ def refresh_next_digest_at def schedule_changed? next_digest_at.nil? || will_save_change_to_digest_frequency? || + will_save_change_to_digest_interval_hours? || + will_save_change_to_digest_start_time? || will_save_change_to_digest_time? || + will_save_change_to_digest_timezone? || will_save_change_to_digest_weekday? end @@ -105,6 +131,34 @@ def saved_change_to_digest_off? digest_frequency == 'off' && saved_change_to_digest_frequency? end + # The start time anchors the wall-clock slots, which continue across midnight. + # Rebuilding them each day avoids job delays and daylight-saving changes + # shifting the user's schedule. + def next_hourly_occurrence(from, hour, minute) + date = from.to_date + candidate_hours = (24 / digest_interval_hours).times.map do |offset| + (hour + (offset * digest_interval_hours)) % 24 + end.sort + candidates = candidate_hours.map do |candidate_hour| + digest_time_zone.local(date.year, date.month, date.day, candidate_hour, minute) + end + + candidates.find { |candidate| candidate > from } || begin + next_date = date + 1.day + digest_time_zone.local(next_date.year, next_date.month, next_date.day, candidate_hours.first, minute) + end + end + + def digest_time_zone + ActiveSupport::TimeZone[resolved_digest_timezone] + end + + def digest_timezone_supported + return if digest_timezone.present? && digest_time_zone.present? + + errors.add(:digest_timezone, 'must be a valid timezone') + end + # Nothing is left waiting for a digest that will never run. def process_pending_notifications now = Time.current diff --git a/db/migrate/20260825000645_create_notifications_and_preferences.rb b/db/migrate/20260825000645_create_notifications_and_preferences.rb index d75de27a39..ea4aa253dd 100644 --- a/db/migrate/20260825000645_create_notifications_and_preferences.rb +++ b/db/migrate/20260825000645_create_notifications_and_preferences.rb @@ -54,7 +54,11 @@ def change t.json :channels, null: false t.string :digest_frequency, null: false, default: 'weekly', limit: 16 + t.integer :digest_interval_hours, null: false, default: 4 + # The wall-clock anchor for intervals that continue through the night. + t.string :digest_start_time, null: false, default: '08:00', limit: 5 t.string :digest_time, null: false, default: '07:00', limit: 5 + t.string :digest_timezone, null: false, limit: 64 t.integer :digest_weekday, null: false, default: 1 t.boolean :weekly_summary, null: false, default: true t.datetime :next_digest_at diff --git a/db/schema.rb b/db/schema.rb index 9383f92f19..b4eb343117 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -376,7 +376,10 @@ t.bigint "user_id", null: false t.text "channels", size: :long, null: false, collation: "utf8mb4_bin" t.string "digest_frequency", limit: 16, default: "weekly", null: false + t.integer "digest_interval_hours", default: 4, null: false + t.string "digest_start_time", limit: 5, default: "08:00", null: false t.string "digest_time", limit: 5, default: "07:00", null: false + t.string "digest_timezone", limit: 64, null: false t.integer "digest_weekday", default: 1, null: false t.boolean "weekly_summary", default: true, null: false t.datetime "next_digest_at" diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index 574f594d8a..52b475e68d 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -131,6 +131,9 @@ def test_settings_start_from_the_defaults assert_equal 200, last_response.status assert_equal 'weekly', last_response_body['digest_frequency'] + assert_equal 4, last_response_body['digest_interval_hours'] + assert_equal '08:00', last_response_body['digest_start_time'] + assert_equal @project.campus.timezone, last_response_body['digest_timezone'] assert_equal %w[in_app email], last_response_body.dig('channels', 'new_task_comment') assert_empty last_response_body['units'] end @@ -139,6 +142,8 @@ def test_updating_settings_stores_the_schedule_and_the_units_that_differ put_json '/api/notification_settings', channels: { new_task_comment: ['in_app'] }, digest_frequency: 'daily', + digest_interval_hours: 6, + digest_start_time: '09:00', digest_time: '10:30', digest_weekday: 1, weekly_summary: false, @@ -146,6 +151,9 @@ def test_updating_settings_stores_the_schedule_and_the_units_that_differ assert_equal 200, last_response.status assert_equal 'daily', last_response_body['digest_frequency'] + assert_equal 6, last_response_body['digest_interval_hours'] + assert_equal '09:00', last_response_body['digest_start_time'] + assert_equal @project.campus.timezone, last_response_body['digest_timezone'] assert_equal ['in_app'], last_response_body.dig('channels', 'new_task_comment') assert_equal [{ 'unit_id' => @unit.id, 'muted' => true, 'channels' => nil }], last_response_body['units'] end @@ -186,6 +194,12 @@ def test_updating_settings_rejects_an_unknown_frequency assert_equal 400, last_response.status end + def test_updating_settings_rejects_an_unknown_digest_interval + put_json '/api/notification_settings', digest_interval_hours: 5 + + assert_equal 400, last_response.status + end + def test_updating_settings_leaves_out_what_was_not_sent put_json '/api/notification_settings', digest_frequency: 'daily' NotificationUnitOverride.create!(user: @student, unit: @unit, muted: true) diff --git a/test/factories/notifications.rb b/test/factories/notifications.rb index 3c1330d3ac..9326499a34 100644 --- a/test/factories/notifications.rb +++ b/test/factories/notifications.rb @@ -9,7 +9,10 @@ factory :notification_setting do user digest_frequency { 'weekly' } + digest_interval_hours { 4 } + digest_start_time { '08:00' } digest_time { '07:00' } + digest_timezone { 'UTC' } digest_weekday { 1 } end diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 5a14334faf..54c2503c13 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -5,7 +5,10 @@ def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push settings = NotificationSetting.for(FactoryBot.create(:user)) assert_equal 'weekly', settings.digest_frequency + assert_equal 4, settings.digest_interval_hours + assert_equal '08:00', settings.digest_start_time assert_equal '07:00', settings.digest_time + assert_equal NotificationSetting.default_digest_timezone, settings.digest_timezone assert_equal 1, settings.digest_weekday assert settings.weekly_summary assert_equal %w[in_app email], settings.channels['new_task_comment'] @@ -74,7 +77,15 @@ def test_a_muted_unit_keeps_following_the_defaults_underneath end def test_daily_delivery_keeps_its_time_across_a_daylight_saving_transition - settings = FactoryBot.build(:notification_setting, digest_frequency: 'daily', digest_time: '09:00') + user = FactoryBot.create(:user) + campus = FactoryBot.create(:campus, timezone: 'Australia/Melbourne') + FactoryBot.create(:project, user: user, campus: campus) + settings = FactoryBot.build( + :notification_setting, + user: user, + digest_frequency: 'daily', + digest_time: '09:00' + ) from = Time.zone.local(2026, 10, 3, 13, 30, 0) next_occurrence = settings.next_occurrence(from) @@ -83,6 +94,51 @@ def test_daily_delivery_keeps_its_time_across_a_daylight_saving_transition assert_equal 9, next_occurrence.hour end + def test_delivery_uses_the_first_enrolled_projects_campus_timezone + user = FactoryBot.create(:user) + first_campus = FactoryBot.create(:campus, timezone: 'Australia/Melbourne') + second_campus = FactoryBot.create(:campus, timezone: 'Pacific/Auckland') + FactoryBot.create(:project, user: user, campus: first_campus, enrolled: true) + FactoryBot.create(:project, user: user, campus: second_campus, enrolled: true) + settings = FactoryBot.build( + :notification_setting, + user: user, + digest_frequency: 'daily', + digest_time: '08:00' + ) + + next_occurrence = settings.next_occurrence(Time.utc(2026, 7, 28, 21, 30)) + + assert_equal Time.utc(2026, 7, 28, 22), next_occurrence + assert_equal 'Australia/Melbourne', next_occurrence.time_zone.name + end + + def test_hourly_delivery_continues_across_midnight_without_drifting + settings = FactoryBot.build( + :notification_setting, + digest_frequency: 'hourly', + digest_interval_hours: 4, + digest_start_time: '08:00' + ) + + assert_equal Time.zone.local(2026, 7, 29, 12), + settings.next_occurrence(Time.zone.local(2026, 7, 29, 10, 30)) + assert_equal Time.zone.local(2026, 7, 30, 0), + settings.next_occurrence(Time.zone.local(2026, 7, 29, 20, 30)) + end + + def test_three_hourly_delivery_continues_from_its_anchor + settings = FactoryBot.build( + :notification_setting, + digest_frequency: 'hourly', + digest_interval_hours: 3, + digest_start_time: '08:00' + ) + + assert_equal Time.zone.local(2026, 7, 30, 2), + settings.next_occurrence(Time.zone.local(2026, 7, 29, 23, 30)) + end + def test_weekly_delivery_lands_on_the_selected_weekday settings = FactoryBot.build( :notification_setting, From af88c29fab8717130a3cbab62fc2794d72afd0ba Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:24:22 +1000 Subject: [PATCH 32/57] chore: fix rubocop --- app/models/notification_setting.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index f0ebadd826..d5042eca8e 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -38,7 +38,7 @@ def self.default_channels end def self.default_digest_timezone - configured_timezone = ENV['TZ'].presence + configured_timezone = ENV.fetch('TZ', nil).presence return configured_timezone if ActiveSupport::TimeZone[configured_timezone].present? Time.zone.tzinfo.name From ccab1a226819e479a9bf926ae10dd4c1b50f3624 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:03:52 +1000 Subject: [PATCH 33/57] fix: timezone --- app/models/notification_setting.rb | 2 +- test/models/notification_setting_test.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index d5042eca8e..89378b1038 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -39,7 +39,7 @@ def self.default_channels def self.default_digest_timezone configured_timezone = ENV.fetch('TZ', nil).presence - return configured_timezone if ActiveSupport::TimeZone[configured_timezone].present? + return configured_timezone if configured_timezone && ActiveSupport::TimeZone[configured_timezone].present? Time.zone.tzinfo.name end diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 54c2503c13..86c4f062c4 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -1,6 +1,14 @@ require 'test_helper' class NotificationSettingTest < ActiveSupport::TestCase + def test_default_digest_timezone_falls_back_when_tz_is_unset + original_timezone = ENV.delete('TZ') + + assert_equal Time.zone.tzinfo.name, NotificationSetting.default_digest_timezone + ensure + ENV['TZ'] = original_timezone if original_timezone + end + def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push settings = NotificationSetting.for(FactoryBot.create(:user)) From a930a11fd96792807a912fd53fce6563f3f95bbe Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:12:17 +1000 Subject: [PATCH 34/57] fix: entity --- app/api/entities/notification_setting_entity.rb | 4 +++- test/models/notification_setting_test.rb | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/api/entities/notification_setting_entity.rb b/app/api/entities/notification_setting_entity.rb index 8cdabee813..ff247bdb6b 100644 --- a/app/api/entities/notification_setting_entity.rb +++ b/app/api/entities/notification_setting_entity.rb @@ -6,7 +6,9 @@ class NotificationSettingEntity < Grape::Entity expose :digest_interval_hours expose :digest_start_time expose :digest_time - expose :digest_timezone, &:resolved_digest_timezone + expose :digest_timezone do |settings, _options| + settings.resolved_digest_timezone + end expose :digest_weekday expose :weekly_summary expose :next_digest_at diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 86c4f062c4..95ba94fb8e 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -128,11 +128,12 @@ def test_hourly_delivery_continues_across_midnight_without_drifting digest_interval_hours: 4, digest_start_time: '08:00' ) + digest_zone = ActiveSupport::TimeZone[settings.resolved_digest_timezone] - assert_equal Time.zone.local(2026, 7, 29, 12), - settings.next_occurrence(Time.zone.local(2026, 7, 29, 10, 30)) - assert_equal Time.zone.local(2026, 7, 30, 0), - settings.next_occurrence(Time.zone.local(2026, 7, 29, 20, 30)) + assert_equal digest_zone.local(2026, 7, 29, 12), + settings.next_occurrence(digest_zone.local(2026, 7, 29, 10, 30)) + assert_equal digest_zone.local(2026, 7, 30, 0), + settings.next_occurrence(digest_zone.local(2026, 7, 29, 20, 30)) end def test_three_hourly_delivery_continues_from_its_anchor @@ -142,9 +143,10 @@ def test_three_hourly_delivery_continues_from_its_anchor digest_interval_hours: 3, digest_start_time: '08:00' ) + digest_zone = ActiveSupport::TimeZone[settings.resolved_digest_timezone] - assert_equal Time.zone.local(2026, 7, 30, 2), - settings.next_occurrence(Time.zone.local(2026, 7, 29, 23, 30)) + assert_equal digest_zone.local(2026, 7, 30, 2), + settings.next_occurrence(digest_zone.local(2026, 7, 29, 23, 30)) end def test_weekly_delivery_lands_on_the_selected_weekday From fd6aaf75ef647ba3cf05c77dbbac13f9222a44e4 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:13:13 +1000 Subject: [PATCH 35/57] feat: improve notification digest ui --- .../communication_email.text.erb | 5 - .../notification_digest.html.erb | 234 +++++++++--------- .../notification_digest.text.erb | 8 +- 3 files changed, 125 insertions(+), 122 deletions(-) diff --git a/app/views/communications_mailer/communication_email.text.erb b/app/views/communications_mailer/communication_email.text.erb index 5ad89c15fb..772977b565 100644 --- a/app/views/communications_mailer/communication_email.text.erb +++ b/app/views/communications_mailer/communication_email.text.erb @@ -1,12 +1,7 @@ -<%# Hi <%= @recipient.nickname.presence || @recipient.first_name %> %> - <% @body_paragraphs.each do |paragraph| %> <%= paragraph %> <% end %> -<%# Cheers, -The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> %> - --- Generated with <%= @doubtfire_product_name %> diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index 66b08a9296..cdb874507c 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -1,129 +1,137 @@ + + + + <%= @doubtfire_product_name %> notifications - -

-

Notification Summary

+ + + + + +
+ + + + + -

- Cheers,
- The <%= @doubtfire_product_name %> Team -

- + + + -
- Unsubscribe | Generated with <%= @doubtfire_product_name %> -
+
+ + + + + + + +
+ Notification Summary +
+ <%= "#{Time.zone.now.day.ordinalize} #{Time.zone.now.strftime('%B %Y')}" %> +
+
+ <%= @doubtfire_product_name %> +
+ +

Hi <%= @recipient.first_name %>,

+ +

+ You have <%= @notification_count %> new + <%= 'notification'.pluralize(@notification_count) %> in <%= @doubtfire_product_name %>. +

+ + + <% @units.each do |section| %> + + + + +
-

Hi <%= @recipient.first_name %>,

+
+ <%= section[:unit].code %> +
+
+ <%= section[:unit].name %> +
-

- You have <%= @notification_count %> new - <%= 'notification'.pluralize(@notification_count) %> in <%= @doubtfire_product_name %>. -

+ <% section[:groups].each do |group| %> + + + + +
+
+ <% if group[:task] %> + <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> + <% elsif group[:message_subject].present? %> + <%= group[:message_subject] %> + <% elsif group[:project_id] %> + Portfolio + <% else %> + Unit notification + <% end %> +
+ <% if group[:task] && group[:task][:student_name].present? %> +
<%= group[:task][:student_name] %>
+ <% end %> +
<%= group[:detail] %>
+
<%= notification_timestamp(group) %>
+ <% if group[:task] %> + + <% elsif group[:project_id] %> + + <% end %> +
+ <% end %> - <% @units.each do |section| %> -

<%= section[:unit].code %> - <%= section[:unit].name %>

+
+ <% end %> - <% section[:groups].each do |group| %> -
- <% if group[:task] %> - <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> - <% elsif group[:project_id] %> - Portfolio - <% else %> - Unit notification - <% end %> -
<%= notification_timestamp(group) %>
-
<%= group[:detail] %>
- <% if group[:task] %> -

- - View this task - -

- <% elsif group[:project_id] %> -

- - View your portfolio - -

- <% end %> -
- <% end %> - <% end %> + + + + +
+ + + + +
+ + View all notifications + +
+
-

- View all notifications and email settings -

+
+
You are receiving this because you have email notifications turned on in <%= @doubtfire_product_name %>.
+ +
+
diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index 4eabaa6db1..f6f1670604 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -4,7 +4,7 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <% @units.each do |section| %> <%= section[:unit].code %> - <%= section[:unit].name %> <% section[:groups].each do |group| %> -- <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %> +- <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group[:message_subject].present? %><%= group[:message_subject] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %> <%= notification_timestamp(group) %> <%= group[:detail] %> <% if group[:task] %> @@ -15,8 +15,8 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <% end %> <% end %> -View all notifications and email settings: +View all notifications: <%= @notification_url %> -Cheers, -The <%= @doubtfire_product_name %> Team +Unsubscribe or change your settings: +<%= @unsubscribe_url %> From a9ab37b015f689a85c268b275f0ee1532e31edbd Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:17:36 +1000 Subject: [PATCH 36/57] chore: improve font --- .../notifications_mailer/notification_digest.html.erb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index cdb874507c..fc29ad55d7 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -17,11 +17,11 @@ -
+ -
+ @@ -49,7 +49,7 @@ <% @units.each do |section| %>
-
+
<%= section[:unit].code %> @@ -61,7 +61,7 @@ <% section[:groups].each do |group| %> - - diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index f6f1670604..ef72612c73 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -3,10 +3,21 @@ Hi <%= @recipient.first_name %>, You have <%= @notification_count %> new <%= 'notification'.pluralize(@notification_count) %> in <%= @doubtfire_product_name %>. <% @units.each do |section| %> <%= section[:unit].code %> - <%= section[:unit].name %> -<% section[:groups].each do |group| %> -- <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group[:message_subject].present? %><%= group[:message_subject] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %> +<% section[:group_sections].each do |group_section| %> +<% if group_section[:divider] %> +------------------- +<% end %> +<% if group_section[:heading] %> +<%= group_section[:heading] %> +<% end %> +<% group_section[:groups].each do |group| %> +- <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group[:message_subject].present? %><%= group[:message_subject] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %><% if group_section[:kind] && group.dig(:task, :due_date) %> — <%= notification_due_date(group) %><% end %> +<% unless group_section[:kind] %> <%= notification_timestamp(group) %> +<% end %> +<% unless group_section[:kind] && group[:event_count] == group[:counts][group_section[:kind]] %> <%= group[:detail] %> +<% end %> <% if group[:task] %> <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> <% elsif group[:project_id] %> @@ -14,6 +25,7 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <% end %> <% end %> <% end %> +<% end %> View all notifications: <%= @notification_url %> diff --git a/config/schedule.yml b/config/schedule.yml index ea392a1fca..18af2b0abd 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -28,6 +28,10 @@ notify_discuss_timeout: cron: "every day at 8am" class: "NotifyDiscussTimeoutJob" +notify_task_deadlines: + cron: "every hour" + class: "NotifyTaskDeadlinesJob" + poll_notification_digests: cron: "every 5 minutes" class: "PollNotificationDigestsJob" diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index baab1a0691..37de9eb3fd 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,7 +6,7 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.destroy_all! Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 9, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 10, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count @@ -16,6 +16,7 @@ def test_jobs_are_scheduled assert_equal 1, AggregateTaskCompletionStatsJob.jobs.count assert_equal 1, PollCommunicationSetSchedulesJob.jobs.count assert_equal 1, NotifyDiscussTimeoutJob.jobs.count + assert_equal 1, NotifyTaskDeadlinesJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end diff --git a/test/sidekiq/task_deadline_notification_job_test.rb b/test/sidekiq/task_deadline_notification_job_test.rb new file mode 100644 index 0000000000..89a7cf3ffc --- /dev/null +++ b/test/sidekiq/task_deadline_notification_job_test.rb @@ -0,0 +1,183 @@ +require 'test_helper' + +class TaskDeadlineNotificationJobTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + def setup + @now = Time.zone.local(2026, 9, 7, 9) + @unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 1, + allow_flexible_dates: true, + start_date: @now - 2.weeks, + end_date: @now + 2.months + ) + @definition = @unit.task_definitions.first + @student = FactoryBot.create(:user, :student) + @project = FactoryBot.create( + :project, + unit: @unit, + user: @student, + enrolled: true, + target_grade: @definition.target_grade + ) + @task = FactoryBot.create( + :task, + project: @project, + task_definition: @definition, + target_start_date: @now.to_date, + target_due_date: (@now + 10.days).to_date + ) + end + + def test_creates_each_start_transition_once + travel_to(@now) do + 2.times { Notification.refresh_task_deadline_notifications! } + end + + assert_equal ['task_start_now'], @student.received_notifications.where(task: @task).pluck(:kind) + end + + def test_creates_due_soon_and_overdue_transitions + travel_to(@now + 5.days) do + Notification.refresh_task_deadline_notifications! + end + travel_to(@now + 11.days) do + Notification.refresh_task_deadline_notifications! + end + + notifications = @student.received_notifications.order(:created_at) + assert_equal %w[task_due_soon task_overdue], notifications.pluck(:kind) + assert_not_nil notifications.first.read_at + assert_nil notifications.last.read_at + assert_equal 'warning', NotificationGroupBuilder.new([notifications.first]).groups.first[:severity] + assert_equal 'critical', NotificationGroupBuilder.new([notifications.last]).groups.first[:severity] + end + + def test_changed_due_date_allows_another_overdue_transition + @task.update!(target_due_date: (@now - 1.day).to_date) + travel_to(@now) { Notification.refresh_task_deadline_notifications! } + + @task.update!(target_due_date: (@now + 10.days).to_date) + travel_to(@now + 11.days) { Notification.refresh_task_deadline_notifications! } + + overdue = @student.received_notifications.where(kind: 'task_overdue').order(:created_at) + assert_equal 2, overdue.count + assert_not_nil overdue.first.read_at + assert_nil overdue.last.read_at + end + + def test_submitted_task_resolves_a_pending_deadline_notification + travel_to(@now) { Notification.refresh_task_deadline_notifications! } + @task.update!(task_status: TaskStatus.ready_for_feedback) + + travel_to(@now + 1.hour) do + Notification.refresh_task_deadline_notifications! + end + + assert_not_nil @student.received_notifications.first.read_at + end + + def test_digest_groups_deadline_notifications_into_sections_and_emails_them_only_once + due_soon_definition = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: @definition.target_grade + ) + overdue_definition = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: @definition.target_grade + ) + due_soon_task = FactoryBot.create( + :task, + project: @project, + task_definition: due_soon_definition, + target_start_date: (@now - 1.week).to_date, + target_due_date: (@now + 5.days).to_date + ) + overdue_task = FactoryBot.create( + :task, + project: @project, + task_definition: overdue_definition, + target_start_date: (@now - 2.weeks).to_date, + target_due_date: (@now - 1.day).to_date + ) + + travel_to(@now) do + Notification.refresh_task_deadline_notifications! + Notification.create_for_portfolio(@project, success: true) + settings = NotificationSetting.for(@student) + + assert_emails 1 do + SendNotificationDigestJob.new.perform(settings.id) + end + text = ActionMailer::Base.deliveries.last.text_part.body.to_s + html = ActionMailer::Base.deliveries.last.html_part.body.to_s + NotificationsMailer::TASK_DEADLINE_SECTIONS.map(&:last).each do |heading| + assert_includes text, heading + assert_includes html, heading + end + [@task, due_soon_task, overdue_task].each do |task| + due_text = "Due #{task.local_due_date.day.ordinalize} #{task.local_due_date.strftime('%B')}" + assert_includes text, due_text + assert_includes html, due_text + end + assert_equal 1, text.scan('7th September 2026 at 09:00').count + assert_includes html, 'align="right"' + start_heading = text.index('Tasks ready to start') + due_heading = text.index('Tasks due within five days') + overdue_heading = text.index('Tasks past due') + assert_operator start_heading, :<, text.index("- #{@definition.abbreviation} -") + assert_operator text.index("- #{@definition.abbreviation} -"), :<, due_heading + assert_operator due_heading, :<, text.index("- #{due_soon_task.task_definition.abbreviation} -") + assert_operator text.index("- #{due_soon_task.task_definition.abbreviation} -"), :<, overdue_heading + assert_operator overdue_heading, :<, text.index("- #{overdue_task.task_definition.abbreviation} -") + updates_heading = text.index('Task updates') + assert_operator text.index("- #{overdue_task.task_definition.abbreviation} -"), :<, updates_heading + assert_operator updates_heading, :<, text.index('- Portfolio') + assert_includes html, 'role="separator"' + assert_no_emails do + SendNotificationDigestJob.new.perform(settings.id) + end + end + + assert_not_nil @student.received_notifications.find_by!(task: @task).email_sent_at + end + + def test_student_can_disable_a_deadline_notification_type + settings = NotificationSetting.for(@student) + settings.update!(channels: settings.channels.merge('task_start_now' => [])) + + travel_to(@now) { Notification.refresh_task_deadline_notifications! } + + assert_not @student.received_notifications.exists?(task: @task, kind: 'task_start_now') + end + + def test_digest_does_not_send_a_reminder_for_an_old_due_date + @task.update!(target_due_date: (@now - 1.day).to_date) + travel_to(@now) { Notification.refresh_task_deadline_notifications! } + notification = @student.received_notifications.find_by!(task: @task, kind: 'task_overdue') + @task.update!(target_due_date: (@now + 10.days).to_date) + + travel_to(@now + 1.hour) do + assert_no_emails do + SendNotificationDigestJob.new.perform(NotificationSetting.for(@student).id) + end + end + + assert_not_nil notification.reload.email_processed_at + assert_nil notification.email_sent_at + end + + def test_creates_the_task_record_when_the_assigned_task_has_not_been_opened + @task.destroy! + @definition.update!(start_date: @now.to_date, target_date: (@now + 10.days).to_date) + + travel_to(@now) { Notification.refresh_task_deadline_notifications! } + + task = @project.tasks.find_by!(task_definition: @definition) + assert @student.received_notifications.exists?(task: task, kind: 'task_start_now') + end +end From c37c12507fd24a2d272a849cf334a047abde456c Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:48:04 +1000 Subject: [PATCH 44/57] fix: template --- app/views/notifications_mailer/notification_digest.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index c950150141..4ee7d97f68 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -70,7 +70,7 @@ <% group_section[:groups].each do |group| %>
+
<% if group[:task] %> <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> @@ -121,7 +121,7 @@
+
You are receiving this because you have email notifications turned on in <%= @doubtfire_product_name %>.
Unsubscribe or change your settings From adcd3abff5d7b617de343e9fd6affefb4452f272 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:36:19 +1000 Subject: [PATCH 37/57] chore: bump migration --- ...b => 20260903013550_create_notifications_and_preferences.rb} | 0 db/schema.rb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename db/migrate/{20260825000645_create_notifications_and_preferences.rb => 20260903013550_create_notifications_and_preferences.rb} (100%) diff --git a/db/migrate/20260825000645_create_notifications_and_preferences.rb b/db/migrate/20260903013550_create_notifications_and_preferences.rb similarity index 100% rename from db/migrate/20260825000645_create_notifications_and_preferences.rb rename to db/migrate/20260903013550_create_notifications_and_preferences.rb diff --git a/db/schema.rb b/db/schema.rb index c80125fcc6..38224c5edf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_08_25_000645) do +ActiveRecord::Schema[8.0].define(version: 2026_09_03_013550) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false From e1fa52a3c274f3a2ae2393a46eedb35cffa9e464 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:03:01 +1000 Subject: [PATCH 38/57] feat: ensure tutor notes notifications persist until manually marked as read --- app/api/entities/tutor_note_entity.rb | 3 + app/api/notifications_api.rb | 12 ++-- app/api/tutor_notes_api.rb | 11 ++-- app/models/notification.rb | 12 +++- app/models/tutor_note.rb | 14 +++++ app/services/notification_group_builder.rb | 18 +++++- test/api/notifications_api_test.rb | 55 +++++++++++++++++++ test/api/tutor_notes_api_test.rb | 45 +++++++++++++++ test/models/notification_test.rb | 64 +++++++++++++++++++--- 9 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 test/api/tutor_notes_api_test.rb diff --git a/app/api/entities/tutor_note_entity.rb b/app/api/entities/tutor_note_entity.rb index 1184b01d2a..074cd155b5 100644 --- a/app/api/entities/tutor_note_entity.rb +++ b/app/api/entities/tutor_note_entity.rb @@ -17,5 +17,8 @@ class TutorNoteEntity < Grape::Entity expose :read_by_unit_role + expose :requires_current_user_read do |tutor_note, options| + tutor_note.requires_read_by?(options[:user]) + end end end diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 2afa56ff7b..7c645ee58a 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -29,14 +29,14 @@ def shown_in_app(notifications) def unread_group_count rows = current_user.received_notifications.unread.pluck(:id, :task_id, :project_id, :unit_id, :kind, :unit_role_id) rows.select { |_id, _task_id, _project_id, unit_id, kind, _unit_role_id| notification_settings.shows_in_app?(unit_id, kind) }.map do |id, task_id, project_id, unit_id, kind, unit_role_id| - if task_id.present? + if Notification::MODERATION_KINDS.include?(kind) + "tutor-notes:#{unit_role_id}:#{task_id}" + elsif task_id.present? "task:#{task_id}" elsif Notification::COMMUNICATION_KINDS.include?(kind) "communication-email:#{id}" elsif Notification::PORTFOLIO_KINDS.include?(kind) "portfolio:#{project_id}" - elsif Notification::MODERATION_KINDS.include?(kind) - "tutor-notes:#{unit_id}:#{unit_role_id}" else "unit:#{unit_id}:#{kind}" end @@ -129,8 +129,7 @@ def accessible_unit_ids end put '/notifications/read' do scope = current_user.received_notifications.where(id: params[:notification_ids]).unread - count = scope.count - Notification.mark_read(scope) + count = Notification.mark_read(scope) { count: count } end @@ -141,8 +140,7 @@ def accessible_unit_ids put '/notifications/read_all' do scope = current_user.received_notifications.unread scope = scope.where(unit_id: params[:unit_id]) if params[:unit_id] - count = scope.count - Notification.mark_read(scope) + count = Notification.mark_read(scope) { count: count } end diff --git a/app/api/tutor_notes_api.rb b/app/api/tutor_notes_api.rb index 5c197d258a..6fb7e54f3d 100644 --- a/app/api/tutor_notes_api.rb +++ b/app/api/tutor_notes_api.rb @@ -37,7 +37,7 @@ def can_access_tutor_notes?(unit, current_user, unit_role) error!({ error: 'You do not have permission to access this.' }, 403) end - result = unit_role.tutor_notes + result = unit_role.tutor_notes.includes(:notifications) present result, with: Entities::TutorNoteEntity, user: current_user end @@ -60,13 +60,16 @@ def can_access_tutor_notes?(unit, current_user, unit_role) tutor_note = unit_role.tutor_notes.find(params[:id]) - current_unit_role = unit.unit_role_for(current_user) + note_is_about_me = unit.unit_role_for(current_user) == unit_role - unless current_unit_role == unit_role && unit_role == tutor_note.unit_role + unless note_is_about_me || tutor_note.notification_for(current_user).present? error!({ error: 'You do not have permission to update this note.' }, 403) end - tutor_note.update!(read_by_unit_role: true) + TutorNote.transaction do + tutor_note.update!(read_by_unit_role: true) if note_is_about_me + Notification.mark_tutor_note_read(current_user, tutor_note) + end true end diff --git a/app/models/notification.rb b/app/models/notification.rb index 23d297d55c..3fc8f0870a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -225,7 +225,10 @@ def self.create_event(**attributes) find_by(recipient: recipient, deduplication_key: deduplication_key) end - def self.mark_read(relation, at: Time.current) + # Moderation notifications stay unread until their recipient marks the tutor note itself as read. + def self.mark_read(relation, at: Time.current, include_moderation: false) + relation = relation.where.not(kind: MODERATION_KINDS) unless include_moderation + # A group must share one exact read timestamp so read history can reconstruct the group. # rubocop:disable Rails/SkipsModelValidations relation.update_all( @@ -243,6 +246,13 @@ def self.mark_task_read(recipient, task) mark_read(where(recipient: recipient, task: task).unread) end + def self.mark_tutor_note_read(recipient, tutor_note) + mark_read( + where(recipient: recipient, tutor_note: tutor_note).unread, + include_moderation: true + ) + end + # Marking a comment unread rewinds the task's read cursor to the comment before # it, so every later comment on that task becomes unread too. Reopen all of # their notifications so the notification list agrees with the comment view. diff --git a/app/models/tutor_note.rb b/app/models/tutor_note.rb index 24aef62440..39399ae007 100644 --- a/app/models/tutor_note.rb +++ b/app/models/tutor_note.rb @@ -5,6 +5,20 @@ class TutorNote < ApplicationRecord belongs_to :reply_to, class_name: "TutorNote", optional: true has_many :notifications, dependent: :destroy + def notification_for(recipient) + notifications.detect { |notification| notification.recipient_id == recipient.id } + end + + # Falls back to read_by_unit_role for notes with no notification, either older + # ones or a recipient who has moderation notifications switched off. + def requires_read_by?(recipient) + notification = notification_for(recipient) + return notification.read_at.nil? unless notification.nil? + return false if user_id == recipient.id + + unit_role.user_id == recipient.id && !read_by_unit_role + end + def task_definition_id task&.task_definition&.id end diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index 8f4f2d5233..b78074e92f 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -18,11 +18,14 @@ def groups def grouping_key(notification) state_key = notification.read_at ? "read:#{notification.read_at.to_f}" : 'unread' return "#{state_key}:communication-email:#{notification.id}" if notification.kind == 'communication_email' + + # A moderation note belongs to one staff member's thread, so it groups by that + # rather than joining the task's other notifications. + return "#{state_key}:tutor-notes:#{notification.unit_role_id}:#{notification.task_id}" if Notification::MODERATION_KINDS.include?(notification.kind) + return "#{state_key}:task:#{notification.task_id}" if notification.task_id.present? return "#{state_key}:portfolio:#{notification.project_id}" if Notification::PORTFOLIO_KINDS.include?(notification.kind) - return "#{state_key}:tutor-notes:#{notification.unit_role_id}" if Notification::MODERATION_KINDS.include?(notification.kind) - "#{state_key}:unit:#{notification.unit_id}:#{notification.kind}" end @@ -62,6 +65,7 @@ def build_group(items) timezone: project&.campus&.timezone || Time.zone.name, tutor_note_ids: tutor_notes.filter_map(&:tutor_note_id), tutor_note_unit_role_id: tutor_notes.first&.unit_role_id, + tutor_note_on_task_tutor: tutor_note_on_task_tutor?(tutor_notes.first, task), overseer_assessment_id: overseer_assessment_id(items), message_subject: latest.message_subject, message_body: latest.message_body, @@ -70,6 +74,16 @@ def build_group(items) } end + # The task's Mod Notes tab shows the notes on its own tutor, so a note about + # anyone else - a convenor's thread that happens to name this task - has to be + # opened as a thread instead. + def tutor_note_on_task_tutor?(notification, task) + return false if notification.nil? || task.nil? + + tutor = task.project.tutor_for(task.task_definition) + tutor.present? && tutor.id == notification.unit_role&.user_id + end + def task_details(task, recipient) return nil if task.nil? diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index 52b475e68d..1dd34c5f0a 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -100,6 +100,16 @@ def test_mark_read_cannot_update_another_users_notification assert_nil other_notification.reload.read_at end + def test_mark_read_cannot_clear_a_tutor_note_notification + notification, = create_tutor_note_notification(recipient: @student) + + put_json '/api/notifications/read', notification_ids: [notification.id] + + assert_equal 200, last_response.status + assert_equal 0, last_response_body['count'] + assert_nil notification.reload.read_at + end + def test_mark_all_read_can_be_scoped_to_a_unit own_notification = Notification.find_by!(recipient: @student) other_unit = FactoryBot.create(:unit, with_students: false, task_count: 0) @@ -112,6 +122,39 @@ def test_mark_all_read_can_be_scoped_to_a_unit assert_nil other_notification.reload.read_at end + def test_mark_all_read_leaves_tutor_note_notifications_unread + notification, = create_tutor_note_notification(recipient: @student) + + put_json '/api/notifications/read_all', {} + + assert_equal 200, last_response.status + assert_nil notification.reload.read_at + end + + def test_marking_a_tutor_note_read_clears_its_recipient_notification + recipient = FactoryBot.create(:user, :convenor) + @unit.employ_staff(recipient, Role.convenor) + notification, tutor_note = create_tutor_note_notification(recipient: recipient) + add_auth_header_for(user: recipient) + + put_json "/api/unit_roles/#{tutor_note.unit_role_id}/tutor_notes/#{tutor_note.id}/mark_as_read", {} + + assert_equal 200, last_response.status + assert_not_nil notification.reload.read_at + assert_not tutor_note.reload.read_by_unit_role + end + + def test_the_tutor_a_note_is_about_still_marks_it_read_for_their_role + notification, tutor_note = create_tutor_note_notification(recipient: @tutor) + add_auth_header_for(user: @tutor) + + put_json "/api/unit_roles/#{tutor_note.unit_role_id}/tutor_notes/#{tutor_note.id}/mark_as_read", {} + + assert_equal 200, last_response.status + assert_not_nil notification.reload.read_at + assert tutor_note.reload.read_by_unit_role + end + def test_a_kind_switched_off_in_app_is_hidden_but_still_emailed settings = NotificationSetting.for(@student) settings.update!(channels: settings.channels.merge('new_task_comment' => ['email'])) @@ -212,4 +255,16 @@ def test_updating_settings_leaves_out_what_was_not_sent assert_equal '06:00', last_response_body['digest_time'] assert_equal 1, @student.notification_unit_overrides.reload.count end + + private + + def create_tutor_note_notification(recipient:) + unit_role = @unit.unit_role_for(@tutor) + author = FactoryBot.create(:user, :convenor) + @unit.employ_staff(author, Role.convenor) + tutor_note = unit_role.add_tutor_note(author, 'Please review this moderation note', @task.id) + notification = Notification.create_for_tutor_note(tutor_note, recipient, 'moderation_note_added') + + [notification, tutor_note] + end end diff --git a/test/api/tutor_notes_api_test.rb b/test/api/tutor_notes_api_test.rb new file mode 100644 index 0000000000..8659955199 --- /dev/null +++ b/test/api/tutor_notes_api_test.rb @@ -0,0 +1,45 @@ +require 'test_helper' + +class TutorNotesApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def setup + super + @unit = FactoryBot.create(:unit, task_count: 1) + @project = @unit.active_projects.first + @task = @project.task_for_task_definition(@unit.task_definitions.first) + @convenor = @unit.main_convenor_user + + @tutor = FactoryBot.create(:user, :tutor) + @unit.employ_staff(@tutor, Role.tutor) + @tutor_role = @unit.unit_role_for(@tutor) + end + + def test_a_note_written_about_you_asks_you_to_mark_it_read + about_me = @tutor_role.add_tutor_note(@convenor, 'Please review this feedback', @task.id) + add_auth_header_for(user: @tutor) + + get "/api/unit_roles/#{@tutor_role.id}/tutor_notes" + + assert_equal 200, last_response.status + note = last_response_body.find { |result| result['id'] == about_me.id } + assert note['requires_current_user_read'] + end + + def test_your_own_note_never_asks_you_to_mark_it_read + mine = @tutor_role.add_tutor_note(@tutor, 'A note on my own moderation notes', @task.id) + add_auth_header_for(user: @tutor) + + get "/api/unit_roles/#{@tutor_role.id}/tutor_notes" + + assert_equal 200, last_response.status + note = last_response_body.find { |result| result['id'] == mine.id } + assert_not note['requires_current_user_read'] + end +end diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index 88493162b3..37d6624d3a 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -109,21 +109,67 @@ def test_discuss_expiry_supersedes_warning_without_hiding_feedback assert_includes group[:summary], 'Discussion deadline missed' end - def test_task_tutor_note_and_student_feedback_share_a_group_with_two_actions + def test_a_tutor_note_groups_apart_from_the_tasks_own_notifications @task.add_text_comment(@student, 'Can you check this change?') unit_role = @unit.unit_role_for(@tutor) tutor_note = unit_role.add_tutor_note(@unit.main_convenor_user, 'Please follow up', @task.id) Notification.create_for_tutor_note(tutor_note, @tutor, 'moderation_note_added') - group = NotificationGroupBuilder.new(Notification.where(recipient: @tutor).unread).groups.first + groups = NotificationGroupBuilder.new(Notification.where(recipient: @tutor).unread).groups + moderation = groups.find { |group| group[:counts]['moderation_note_added'].positive? } + comments = groups.find { |group| group[:counts]['new_task_comment'].positive? } + + assert_equal 2, groups.count + assert_equal 0, moderation[:counts]['new_task_comment'] + assert_equal [tutor_note.id], moderation[:tutor_note_ids] + assert_equal [Notification.find_by!(tutor_note: tutor_note).id], moderation[:tutor_note_notification_ids] + assert_equal unit_role.id, moderation[:tutor_note_unit_role_id] + assert moderation[:tutor_note_on_task_tutor] + assert_includes moderation[:detail], "1 moderation note from #{@unit.main_convenor_user.name}" + assert comments.dig(:task, :staff_view) + assert_equal @student.name, comments.dig(:task, :student_name) + end - assert_equal 1, group[:counts]['new_task_comment'] - assert_equal 1, group[:counts]['moderation_note_added'] - assert_equal [tutor_note.id], group[:tutor_note_ids] - assert_equal [Notification.find_by!(tutor_note: tutor_note).id], group[:tutor_note_notification_ids] - assert group.dig(:task, :staff_view) - assert_equal @student.name, group.dig(:task, :student_name) - assert_includes group[:detail], "1 moderation note from #{@unit.main_convenor_user.name}" + # A group opens one staff member's thread, so notes about two of them on the same + # task cannot share a row - the count would promise more than the thread shows. + def test_notes_about_different_staff_on_one_task_group_apart + other_staff = FactoryBot.create(:user, :tutor) + @unit.employ_staff(other_staff, Role.tutor) + author = @unit.main_convenor_user + about_tutor = @unit.unit_role_for(@tutor).add_tutor_note(author, 'About the tutor', @task.id) + about_other = @unit.unit_role_for(other_staff).add_tutor_note(author, 'About the other tutor', @task.id) + Notification.create_for_tutor_note(about_tutor, @tutor, 'moderation_note_added') + Notification.create_for_tutor_note(about_other, @tutor, 'moderation_note_reply') + + groups = NotificationGroupBuilder.new(Notification.where(recipient: @tutor).unread).groups + + assert_equal 2, groups.count + assert_equal [[about_tutor.id], [about_other.id]].sort, groups.map { |group| group[:tutor_note_ids] }.sort + # Only the task's own tutor's thread can be opened from the task's Mod Notes tab. + assert_equal [false, true], groups.map { |group| group[:tutor_note_on_task_tutor] }.sort_by(&:to_s) + end + + def test_mark_task_read_does_not_clear_a_tutor_note_notification + unit_role = @unit.unit_role_for(@tutor) + tutor_note = unit_role.add_tutor_note(@unit.main_convenor_user, 'Please follow up', @task.id) + notification = Notification.create_for_tutor_note(tutor_note, @tutor, 'moderation_note_added') + + Notification.mark_task_read(@tutor, @task) + + assert_nil notification.reload.read_at + end + + def test_mark_tutor_note_read_clears_only_the_named_recipients_notification + unit_role = @unit.unit_role_for(@tutor) + tutor_note = unit_role.add_tutor_note(@unit.main_convenor_user, 'Please follow up', @task.id) + tutor_notification = Notification.create_for_tutor_note(tutor_note, @tutor, 'moderation_note_added') + other_recipient = FactoryBot.create(:user) + other_notification = Notification.create_for_tutor_note(tutor_note, other_recipient, 'moderation_note_reply') + + Notification.mark_tutor_note_read(@tutor, tutor_note) + + assert_not_nil tutor_notification.reload.read_at + assert_nil other_notification.reload.read_at end def test_duplicate_source_event_is_deduplicated_per_recipient From 4d21b2dd19c8262ef8571ef374c7c6ed46b0133c Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:04:01 +1000 Subject: [PATCH 39/57] chore: default interval start time at 7am --- .../20260903013550_create_notifications_and_preferences.rb | 2 +- db/schema.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20260903013550_create_notifications_and_preferences.rb b/db/migrate/20260903013550_create_notifications_and_preferences.rb index ea4aa253dd..0d6fc0929a 100644 --- a/db/migrate/20260903013550_create_notifications_and_preferences.rb +++ b/db/migrate/20260903013550_create_notifications_and_preferences.rb @@ -56,7 +56,7 @@ def change t.string :digest_frequency, null: false, default: 'weekly', limit: 16 t.integer :digest_interval_hours, null: false, default: 4 # The wall-clock anchor for intervals that continue through the night. - t.string :digest_start_time, null: false, default: '08:00', limit: 5 + t.string :digest_start_time, null: false, default: '07:00', limit: 5 t.string :digest_time, null: false, default: '07:00', limit: 5 t.string :digest_timezone, null: false, limit: 64 t.integer :digest_weekday, null: false, default: 1 diff --git a/db/schema.rb b/db/schema.rb index 38224c5edf..3e9d5d6a6d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -378,7 +378,7 @@ t.text "channels", size: :long, null: false, collation: "utf8mb4_bin" t.string "digest_frequency", limit: 16, default: "weekly", null: false t.integer "digest_interval_hours", default: 4, null: false - t.string "digest_start_time", limit: 5, default: "08:00", null: false + t.string "digest_start_time", limit: 5, default: "07:00", null: false t.string "digest_time", limit: 5, default: "07:00", null: false t.string "digest_timezone", limit: 64, null: false t.integer "digest_weekday", default: 1, null: false From 0c766d896951d09dfaa8904e5c9203daf5d9d640 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:38:12 +1000 Subject: [PATCH 40/57] chore: disable weekly emails --- .ci-setup/crontab | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.ci-setup/crontab b/.ci-setup/crontab index b1298d5e39..a150f953b5 100644 --- a/.ci-setup/crontab +++ b/.ci-setup/crontab @@ -6,5 +6,6 @@ PATH=/tmp/texlive/bin/x86_64-linux:/tmp/texlive/bin/aarch64-linux:/usr/local/bun 0,10,20,30,40,50 * * * * /doubtfire/lib/shell/send_overseer_notifications.sh 0 5 * * * /doubtfire/lib/shell/check_plagiarism.sh 0 8 * * * /doubtfire/lib/shell/portfolio_autogen_check.sh -0 7 * * 1 /doubtfire/lib/shell/send_weekly_emails.sh +# Deprecated: notification digests replace the weekly progress summary. +# 0 7 * * 1 /doubtfire/lib/shell/send_weekly_emails.sh 0 1 * * * /doubtfire/lib/shell/sync_enrolments.sh From 1d539ba27ee0d7f261dd5d2b92dede1b80dd8671 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:41:09 +1000 Subject: [PATCH 41/57] chore: remove weekly summary option from notifications --- app/api/entities/notification_setting_entity.rb | 1 - app/api/notifications_api.rb | 1 - app/models/notification_setting.rb | 2 -- .../20260903013550_create_notifications_and_preferences.rb | 1 - db/schema.rb | 1 - test/api/notifications_api_test.rb | 3 --- test/models/notification_setting_test.rb | 7 +------ 7 files changed, 1 insertion(+), 15 deletions(-) diff --git a/app/api/entities/notification_setting_entity.rb b/app/api/entities/notification_setting_entity.rb index ff247bdb6b..baa18d3551 100644 --- a/app/api/entities/notification_setting_entity.rb +++ b/app/api/entities/notification_setting_entity.rb @@ -10,7 +10,6 @@ class NotificationSettingEntity < Grape::Entity settings.resolved_digest_timezone end expose :digest_weekday - expose :weekly_summary expose :next_digest_at expose :last_digest_at expose :units, using: NotificationUnitOverrideEntity do |settings| diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 7c645ee58a..a8540e5516 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -157,7 +157,6 @@ def accessible_unit_ids optional :digest_start_time, type: String optional :digest_time, type: String optional :digest_weekday, type: Integer - optional :weekly_summary, type: Boolean optional :units, type: Array do requires :unit_id, type: Integer requires :muted, type: Boolean diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 89378b1038..991aacb316 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -65,8 +65,6 @@ def delivers?(unit, kind, channel) end def weekly_summary_for?(unit) - return false unless weekly_summary - override = user.notification_unit_overrides.find_by(unit_id: unit&.id) !override&.muted end diff --git a/db/migrate/20260903013550_create_notifications_and_preferences.rb b/db/migrate/20260903013550_create_notifications_and_preferences.rb index 0d6fc0929a..65ae1e0965 100644 --- a/db/migrate/20260903013550_create_notifications_and_preferences.rb +++ b/db/migrate/20260903013550_create_notifications_and_preferences.rb @@ -60,7 +60,6 @@ def change t.string :digest_time, null: false, default: '07:00', limit: 5 t.string :digest_timezone, null: false, limit: 64 t.integer :digest_weekday, null: false, default: 1 - t.boolean :weekly_summary, null: false, default: true t.datetime :next_digest_at t.datetime :last_digest_at diff --git a/db/schema.rb b/db/schema.rb index 3e9d5d6a6d..19a492e87d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -382,7 +382,6 @@ t.string "digest_time", limit: 5, default: "07:00", null: false t.string "digest_timezone", limit: 64, null: false t.integer "digest_weekday", default: 1, null: false - t.boolean "weekly_summary", default: true, null: false t.datetime "next_digest_at" t.datetime "last_digest_at" t.datetime "created_at", null: false diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index 1dd34c5f0a..4ba973ab1a 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -189,7 +189,6 @@ def test_updating_settings_stores_the_schedule_and_the_units_that_differ digest_start_time: '09:00', digest_time: '10:30', digest_weekday: 1, - weekly_summary: false, units: [{ unit_id: @unit.id, muted: true }] assert_equal 200, last_response.status @@ -209,7 +208,6 @@ def test_updating_settings_drops_units_that_no_longer_differ digest_frequency: 'daily', digest_time: '10:30', digest_weekday: 1, - weekly_summary: true, units: [] assert_equal 200, last_response.status @@ -224,7 +222,6 @@ def test_updating_settings_ignores_units_the_user_cannot_access digest_frequency: 'daily', digest_time: '10:30', digest_weekday: 1, - weekly_summary: true, units: [{ unit_id: inaccessible.id, muted: true }] assert_equal 200, last_response.status diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 95ba94fb8e..80a9b9679d 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -18,7 +18,6 @@ def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push assert_equal '07:00', settings.digest_time assert_equal NotificationSetting.default_digest_timezone, settings.digest_timezone assert_equal 1, settings.digest_weekday - assert settings.weekly_summary assert_equal %w[in_app email], settings.channels['new_task_comment'] assert_equal ['in_app'], settings.channels['communication_email'] assert_equal Notification::KINDS.sort, settings.channels.keys.sort @@ -54,7 +53,7 @@ def test_a_muted_unit_delivers_nothing assert_empty settings.channels_for_unit_id(unit.id, 'new_task_comment') end - def test_weekly_summary_honours_the_global_setting_and_unit_mute + def test_weekly_summary_honours_the_unit_mute settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) @@ -62,10 +61,6 @@ def test_weekly_summary_honours_the_global_setting_and_unit_mute FactoryBot.create(:notification_unit_override, user: settings.user, unit: unit, muted: true) assert_not settings.weekly_summary_for?(unit) - - settings.update!(weekly_summary: false) - other_unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - assert_not settings.weekly_summary_for?(other_unit) end def test_a_muted_unit_keeps_following_the_defaults_underneath From d635d864bc2dacaa69b4c25007dbba5f0624d866 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:49:21 +1000 Subject: [PATCH 42/57] fix: test --- test/api/notifications_api_test.rb | 2 +- test/models/notification_setting_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index 4ba973ab1a..f204ca014e 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -175,7 +175,7 @@ def test_settings_start_from_the_defaults assert_equal 200, last_response.status assert_equal 'weekly', last_response_body['digest_frequency'] assert_equal 4, last_response_body['digest_interval_hours'] - assert_equal '08:00', last_response_body['digest_start_time'] + assert_equal '07:00', last_response_body['digest_start_time'] assert_equal @project.campus.timezone, last_response_body['digest_timezone'] assert_equal %w[in_app email], last_response_body.dig('channels', 'new_task_comment') assert_empty last_response_body['units'] diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 80a9b9679d..554cdb4ac3 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -14,7 +14,7 @@ def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push assert_equal 'weekly', settings.digest_frequency assert_equal 4, settings.digest_interval_hours - assert_equal '08:00', settings.digest_start_time + assert_equal '07:00', settings.digest_start_time assert_equal '07:00', settings.digest_time assert_equal NotificationSetting.default_digest_timezone, settings.digest_timezone assert_equal 1, settings.digest_weekday From 545aae6d3fe132950153819ab612cfbb6b447ec6 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:46:00 +1000 Subject: [PATCH 43/57] feat: create notifications for tasks approaching due dates --- app/mailers/notifications_mailer.rb | 45 ++++- app/models/notification.rb | 79 +++++++- app/services/notification_group_builder.rb | 7 +- app/sidekiq/notify_task_deadlines_job.rb | 14 ++ app/sidekiq/send_notification_digest_job.rb | 5 +- .../notification_digest.html.erb | 89 +++++---- .../notification_digest.text.erb | 16 +- config/schedule.yml | 4 + test/sidekiq/scheduled_job_test.rb | 3 +- .../task_deadline_notification_job_test.rb | 183 ++++++++++++++++++ 10 files changed, 403 insertions(+), 42 deletions(-) create mode 100644 app/sidekiq/notify_task_deadlines_job.rb create mode 100644 test/sidekiq/task_deadline_notification_job_test.rb diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index e1a055c15d..f3bc00c774 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -1,4 +1,10 @@ class NotificationsMailer < ApplicationMailer + TASK_DEADLINE_SECTIONS = [ + ['task_start_now', 'Tasks ready to start'], + ['task_due_soon', 'Tasks due within five days'], + ['task_overdue', 'Tasks past due'] + ].freeze + layout 'discussion_deadline_mailer', only: %i[discussion_deadline_approaching discussion_deadline_missed] @@ -90,7 +96,25 @@ def notification_digest(recipient, notifications) @recipient = recipient @notification_url = "#{@doubtfire_host}/notifications" @units = notifications.group_by(&:unit).sort_by { |unit, _| unit.code }.map do |unit, for_unit| - { unit: unit, groups: NotificationGroupBuilder.new(for_unit).groups } + groups = NotificationGroupBuilder.new(for_unit).groups + add_task_deadline_due_dates!(groups, for_unit) + deadline_groups = TASK_DEADLINE_SECTIONS.map do |kind, heading| + { heading: heading, kind: kind, groups: groups.select { |group| group[:counts][kind].positive? } } + end + deadline_groups.select! { |section| section[:groups].any? } + grouped_deadline_notifications = deadline_groups.flat_map { |section| section[:groups] } + other_groups = groups.reject { |group| grouped_deadline_notifications.include?(group) } + group_sections = deadline_groups.dup + if other_groups.any? + group_sections << { + heading: deadline_groups.any? ? 'Task updates' : nil, + divider: deadline_groups.any?, + kind: nil, + groups: other_groups + } + end + + { unit: unit, groups: groups, group_sections: group_sections } end # Grouped events, so the count matches the rows the reader can see below. @notification_count = @units.sum { |section| section[:groups].count } @@ -108,6 +132,11 @@ def notification_timestamp(group) "#{time.day.ordinalize} #{time.strftime('%B %Y at %H:%M')}" end + def notification_due_date(group) + due_date = group.dig(:task, :due_date) + "Due #{due_date.day.ordinalize} #{due_date.strftime('%B')}" + end + def discussion_deadline_approaching(task, sender, expiry_date) add_discussion_deadline_details(task, sender) @deadline = task.unit.formatted_discuss_timeout_date(expiry_date) @@ -162,9 +191,23 @@ def this_these(num) helper_method :are_is helper_method :this_these helper_method :notification_timestamp + helper_method :notification_due_date private + def add_task_deadline_due_dates!(groups, notifications) + due_dates = notifications.filter_map do |notification| + next unless Notification::TASK_DEADLINE_KINDS.include?(notification.kind) && notification.task.present? + + [notification.task_id, notification.task.local_due_date.to_date] + end.to_h + + groups.each do |group| + task = group[:task] + task[:due_date] = due_dates[task[:id]] if task.present? && due_dates.key?(task[:id]) + end + end + def add_discussion_deadline_details(task, sender) add_general @task = task diff --git a/app/models/notification.rb b/app/models/notification.rb index 3fc8f0870a..52213eb35a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -4,6 +4,9 @@ class Notification < ApplicationRecord KINDS = %w[ new_task_comment task_status_changed + task_start_now + task_due_soon + task_overdue overseer_failed pdf_generation_failed discuss_warning @@ -21,6 +24,7 @@ class Notification < ApplicationRecord MODERATION_KINDS = %w[moderation_note_added moderation_note_reply moderation_note_from_mentee].freeze PORTFOLIO_KINDS = %w[portfolio_ready portfolio_failed].freeze COMMUNICATION_KINDS = %w[communication_email].freeze + TASK_DEADLINE_KINDS = %w[task_start_now task_due_soon task_overdue].freeze belongs_to :recipient, class_name: 'User', inverse_of: :received_notifications belongs_to :unit @@ -225,6 +229,49 @@ def self.create_event(**attributes) find_by(recipient: recipient, deduplication_key: deduplication_key) end + def self.refresh_task_deadline_notifications!(now: Time.current) + stale = where(kind: TASK_DEADLINE_KINDS).unread.includes(task: [:task_definition, { project: %i[unit campus user] }]) + stale.find_each do |notification| + mark_read(where(id: notification.id)) unless notification.current_task_deadline?(now: now) + end + + created = 0 + each_task_deadline_candidate(now: now) do |task| + kind = task_deadline_kind(task, now: now) + next if kind.nil? + + notification = create_event( + recipient: task.student, + unit: task.unit, + project: task.project, + task: task, + kind: kind, + deduplication_key: task_deadline_key(task, kind) + ) + created += 1 if notification&.previously_new_record? + end + created + end + + def self.task_deadline_kind(task, now: Time.current) + return nil unless task_deadline_eligible?(task, now: now) + + today = now.in_time_zone(task.project.campus&.timezone.presence || Time.zone.name).to_date + start_date = task.local_start_date.to_date + due_date = task.local_due_date.to_date + + return 'task_overdue' if today > due_date + return 'task_due_soon' if today >= due_date - 5.days + return 'task_start_now' if today >= start_date + + nil + end + + def self.task_deadline_key(task, kind) + date = kind == 'task_start_now' ? task.local_start_date : task.local_due_date + "task-deadline:#{task.id}:#{kind}:#{date.to_date.iso8601}" + end + # Moderation notifications stay unread until their recipient marks the tutor note itself as read. def self.mark_read(relation, at: Time.current, include_moderation: false) relation = relation.where.not(kind: MODERATION_KINDS) unless include_moderation @@ -291,6 +338,14 @@ def email_ready?(at: Time.current) email_not_before.blank? || email_not_before <= at end + def current_task_deadline?(now: Time.current) + return true unless TASK_DEADLINE_KINDS.include?(kind) + return false if task.nil? + + current_kind = self.class.task_deadline_kind(task, now: now) + current_kind == kind && deduplication_key == self.class.task_deadline_key(task, kind) + end + def self.kind_for_comment(comment) case comment when TaskStatusComment @@ -343,5 +398,27 @@ def self.student_actor?(comment) comment.user == comment.project.student || comment.task.role_for(comment.user).in?(%i[student group_member]) end - private_class_method :kind_for_comment, :discuss_deadline_for, :recipients_for_comment, :student_actor? + def self.each_task_deadline_candidate(now:) + Unit.set_active.current_for_date(now).where(send_notifications: true).find_each do |unit| + unit.active_projects.includes(:user, :campus).find_each do |project| + project.assigned_task_defs.find_each do |task_definition| + yield project.task_for_task_definition(task_definition) + end + end + end + end + + def self.task_deadline_eligible?(task, now:) + task.project.enrolled && + task.unit.active && + task.unit.send_notifications && + task.unit.start_date <= now && + task.unit.end_date >= now && + task.task_definition.target_grade <= task.project.target_grade && + task.submission_date.nil? && + !task.submitted_status? + end + + private_class_method :kind_for_comment, :discuss_deadline_for, :recipients_for_comment, :student_actor?, + :each_task_deadline_candidate, :task_deadline_eligible? end diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index b78074e92f..ef3679edc8 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -102,8 +102,8 @@ def task_details(task, recipient) def severity_for(items) kinds = items.map(&:kind) - return 'critical' if kinds.intersect?(%w[discuss_expired pdf_generation_failed portfolio_failed]) - return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed] + Notification::MODERATION_KINDS) + return 'critical' if kinds.intersect?(%w[discuss_expired pdf_generation_failed portfolio_failed task_overdue]) + return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed task_due_soon] + Notification::MODERATION_KINDS) 'normal' end @@ -138,6 +138,9 @@ def detail_for(items, counts, latest_status) details << 'overseer assessment failed' if counts['overseer_failed'].positive? details << 'portfolio compilation failed' if counts['portfolio_failed'].positive? details << 'portfolio ready to review' if counts['portfolio_ready'].positive? + details << 'ready to start now' if counts['task_start_now'].positive? + details << 'due within 5 days' if counts['task_due_soon'].positive? + details << 'past its due date' if counts['task_overdue'].positive? moderation_notifications = items.select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } if moderation_notifications.any? staff_names = moderation_notifications.filter_map { |notification| notification.actor&.name }.uniq diff --git a/app/sidekiq/notify_task_deadlines_job.rb b/app/sidekiq/notify_task_deadlines_job.rb new file mode 100644 index 0000000000..4cd876d991 --- /dev/null +++ b/app/sidekiq/notify_task_deadlines_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotifyTaskDeadlinesJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['notify-task-deadlines'] }, + on_conflict: :reject, + retry: 1 + + def perform + Notification.refresh_task_deadline_notifications! + end +end diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index 6f59ddc69a..7382bf5ae7 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -19,7 +19,7 @@ def perform(setting_id) .email_ready(now) .includes(:recipient, :actor, :unit, { project: :campus }, task: [:task_definition, { project: :user }]) .to_a - deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification) } + deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification, now) } mark_processed(skipped, now) if deliverable.any? @@ -32,9 +32,10 @@ def perform(setting_id) private - def deliverable?(setting, notification) + def deliverable?(setting, notification, now) notification.unit.send_notifications && !notification.recipient_withdrawn? && + notification.current_task_deadline?(now: now) && setting.delivers?(notification.unit, notification.kind, :email) end diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index fc29ad55d7..c950150141 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -58,42 +58,65 @@ <%= section[:unit].name %>
- <% section[:groups].each do |group| %> - - - + +
-
+ <% section[:group_sections].each do |group_section| %> + <% if group_section[:divider] %> +
+ <% end %> + <% if group_section[:heading] %> +
+ <%= group_section[:heading] %> +
+ <% end %> + <% group_section[:groups].each do |group| %> + + + - -
+ + + + <% if group_section[:kind] && group.dig(:task, :due_date) %> + + <% end %> + +
+ <% if group[:task] %> + <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> + <% elsif group[:message_subject].present? %> + <%= group[:message_subject] %> + <% elsif group[:project_id] %> + Portfolio + <% else %> + Unit notification + <% end %> + + <%= notification_due_date(group) %> +
+ <% if group[:task] && group[:task][:student_name].present? %> +
<%= group[:task][:student_name] %>
+ <% end %> + <% unless group_section[:kind] && group[:event_count] == group[:counts][group_section[:kind]] %> +
<%= group[:detail] %>
+ <% end %> + <% unless group_section[:kind] %> +
<%= notification_timestamp(group) %>
+ <% end %> <% if group[:task] %> - <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> - <% elsif group[:message_subject].present? %> - <%= group[:message_subject] %> + <% elsif group[:project_id] %> - Portfolio - <% else %> - Unit notification + <% end %> - - <% if group[:task] && group[:task][:student_name].present? %> -
<%= group[:task][:student_name] %>
- <% end %> -
<%= group[:detail] %>
-
<%= notification_timestamp(group) %>
- <% if group[:task] %> - - <% elsif group[:project_id] %> - - <% end %> -
+
+ <% end %> <% end %>
-
+
From ff1779a956211b52614be77b5075771c3caaf60b Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:41:57 +1000 Subject: [PATCH 45/57] chore: abstract method --- app/services/notification_group_builder.rb | 71 +++++++++++++++------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index ef3679edc8..f757655ab2 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -1,6 +1,18 @@ # frozen_string_literal: true class NotificationGroupBuilder + EVENT_DETAILS = { + 'discuss_expired' => 'discussion deadline missed', + 'discuss_warning' => 'discussion deadline approaching', + 'pdf_generation_failed' => 'submission PDF generation failed', + 'overseer_failed' => 'overseer assessment failed', + 'portfolio_failed' => 'portfolio compilation failed', + 'portfolio_ready' => 'portfolio ready to review', + 'task_start_now' => 'ready to start now', + 'task_due_soon' => 'due within 5 days', + 'task_overdue' => 'past its due date' + }.freeze + def initialize(notifications) @notifications = notifications.to_a end @@ -127,35 +139,48 @@ def subject_for(task, recipient, counts, latest) # What happened, without the task it happened to, so callers can show the two # separately rather than splitting the summary back apart. def detail_for(items, counts, latest_status) - details = [] - if counts['communication_email'].positive? - sender = items.max_by(&:created_at).actor&.name - details << (sender.present? ? "Message from #{sender}" : 'Message') - end - details << 'discussion deadline missed' if counts['discuss_expired'].positive? - details << 'discussion deadline approaching' if counts['discuss_warning'].positive? - details << 'submission PDF generation failed' if counts['pdf_generation_failed'].positive? - details << 'overseer assessment failed' if counts['overseer_failed'].positive? - details << 'portfolio compilation failed' if counts['portfolio_failed'].positive? - details << 'portfolio ready to review' if counts['portfolio_ready'].positive? - details << 'ready to start now' if counts['task_start_now'].positive? - details << 'due within 5 days' if counts['task_due_soon'].positive? - details << 'past its due date' if counts['task_overdue'].positive? - moderation_notifications = items.select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } - if moderation_notifications.any? - staff_names = moderation_notifications.filter_map { |notification| notification.actor&.name }.uniq - moderation_detail = pluralize(moderation_notifications.count, 'moderation note') - moderation_detail += " from #{staff_names.to_sentence}" if staff_names.any? - details << moderation_detail - end - details << pluralize(counts['new_task_comment'], 'new comment') if counts['new_task_comment'].positive? - details << "task status changed to #{status_name(latest_status)}" if latest_status.present? + details = [ + communication_detail(items, counts), + *event_details(counts), + moderation_detail(items), + comment_detail(counts), + status_detail(latest_status) + ].compact # Sentence case, so a single detail reads as a heading and several still join # into one readable sentence. details.to_sentence.upcase_first end + def communication_detail(items, counts) + return unless counts['communication_email'].positive? + + sender = items.max_by(&:created_at).actor&.name + sender.present? ? "Message from #{sender}" : 'Message' + end + + def event_details(counts) + EVENT_DETAILS.filter_map { |kind, detail| detail if counts[kind].positive? } + end + + def moderation_detail(items) + moderation_notifications = items.select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } + return if moderation_notifications.empty? + + staff_names = moderation_notifications.filter_map { |notification| notification.actor&.name }.uniq + detail = pluralize(moderation_notifications.count, 'moderation note') + staff_names.any? ? "#{detail} from #{staff_names.to_sentence}" : detail + end + + def comment_detail(counts) + count = counts['new_task_comment'] + pluralize(count, 'new comment') if count.positive? + end + + def status_detail(latest_status) + "task status changed to #{status_name(latest_status)}" if latest_status.present? + end + def status_name(status_key) TaskStatus.status_for_name(status_key.to_s)&.name || status_key.to_s.humanize end From 6d2d4fcbbcc101cc239fc1a8db6c60403ace2750 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:25:53 +1000 Subject: [PATCH 46/57] chore: remove deprecated styles --- .../notification_digest.html.erb | 227 ++++++++---------- test/sidekiq/notification_jobs_test.rb | 2 + .../task_deadline_notification_job_test.rb | 4 +- 3 files changed, 98 insertions(+), 135 deletions(-) diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index 4ee7d97f68..1d0ed757f1 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -11,150 +11,111 @@ @media only screen and (max-width: 640px) { .main { padding: 20px 16px !important; } .unit-card { padding: 14px 16px !important; } + .notification-heading { align-items: flex-start !important; flex-direction: column !important; gap: 2px !important; } } - - - - -
- +
+
+
+
+
+ Notification Summary +
+ <%= "#{Time.zone.now.day.ordinalize} #{Time.zone.now.strftime('%B %Y')}" %> +
+
+ <%= @doubtfire_product_name %> +
-
- - - - - - + + -
+

Hi <%= @recipient.first_name %>,

- - - - - - -
- Notification Summary -
- <%= "#{Time.zone.now.day.ordinalize} #{Time.zone.now.strftime('%B %Y')}" %> -
-
- <%= @doubtfire_product_name %> -
- -

Hi <%= @recipient.first_name %>,

- -

- You have <%= @notification_count %> new - <%= 'notification'.pluralize(@notification_count) %> in <%= @doubtfire_product_name %>. -

- - - <% @units.each do |section| %> - - - - -
+

+ You have <%= @notification_count %> new + <%= 'notification'.pluralize(@notification_count) %> in <%= @doubtfire_product_name %>. +

-
- <%= section[:unit].code %> -
-
- <%= section[:unit].name %> -
+ <% @units.each do |section| %> +
+
+ <%= section[:unit].code %> +
+
+ <%= section[:unit].name %> +
- <% section[:group_sections].each do |group_section| %> - <% if group_section[:divider] %> -
- <% end %> - <% if group_section[:heading] %> -
- <%= group_section[:heading] %> -
- <% end %> - <% group_section[:groups].each do |group| %> - - - - -
- - - - <% if group_section[:kind] && group.dig(:task, :due_date) %> - - <% end %> - -
- <% if group[:task] %> - <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> - <% elsif group[:message_subject].present? %> - <%= group[:message_subject] %> - <% elsif group[:project_id] %> - Portfolio - <% else %> - Unit notification - <% end %> - - <%= notification_due_date(group) %> -
- <% if group[:task] && group[:task][:student_name].present? %> -
<%= group[:task][:student_name] %>
- <% end %> - <% unless group_section[:kind] && group[:event_count] == group[:counts][group_section[:kind]] %> -
<%= group[:detail] %>
- <% end %> - <% unless group_section[:kind] %> -
<%= notification_timestamp(group) %>
- <% end %> - <% if group[:task] %> - - <% elsif group[:project_id] %> - - <% end %> -
- <% end %> + <% section[:group_sections].each do |group_section| %> + <% if group_section[:divider] %> +
+ <% end %> + <% if group_section[:heading] %> +
+ <%= group_section[:heading] %> +
+ <% end %> + <% group_section[:groups].each do |group| %> +
+
+
+ <% if group[:task] %> + <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> + <% elsif group[:message_subject].present? %> + <%= group[:message_subject] %> + <% elsif group[:project_id] %> + Portfolio + <% else %> + Unit notification <% end %> - -
+ + <% if group_section[:kind] && group.dig(:task, :due_date) %> +
+ <%= notification_due_date(group) %> +
+ <% end %> + + <% if group[:task] && group[:task][:student_name].present? %> +
<%= group[:task][:student_name] %>
+ <% end %> + <% unless group_section[:kind] && group[:event_count] == group[:counts][group_section[:kind]] %> +
<%= group[:detail] %>
+ <% end %> + <% unless group_section[:kind] %> +
<%= notification_timestamp(group) %>
+ <% end %> + <% if group[:task] %> + + <% elsif group[:project_id] %> + + <% end %> + <% end %> + <% end %> + + <% end %> - - - - -
- - - - -
- - View all notifications - -
-
- -
-
You are receiving this because you have email notifications turned on in <%= @doubtfire_product_name %>.
- -
-
+ + + diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index f7e1667f18..d8d2117960 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -31,6 +31,8 @@ def test_digest_sends_only_the_events_enabled_on_the_email_channel assert_includes digest_html, 'notification-box' assert_includes digest_html, 'Unsubscribe' assert_includes digest_html, unit.code + assert_no_match(/<(?:table|tr|td)\b/, digest_html) + assert_no_match(/\s(?:width|cellpadding|cellspacing|align|valign|nowrap)=/, digest_html) assert_not_nil enabled.reload.email_sent_at assert_not_nil disabled.reload.email_processed_at diff --git a/test/sidekiq/task_deadline_notification_job_test.rb b/test/sidekiq/task_deadline_notification_job_test.rb index 89a7cf3ffc..c3e1289909 100644 --- a/test/sidekiq/task_deadline_notification_job_test.rb +++ b/test/sidekiq/task_deadline_notification_job_test.rb @@ -125,7 +125,7 @@ def test_digest_groups_deadline_notifications_into_sections_and_emails_them_only assert_includes html, due_text end assert_equal 1, text.scan('7th September 2026 at 09:00').count - assert_includes html, 'align="right"' + assert_includes html, 'justify-content:space-between' start_heading = text.index('Tasks ready to start') due_heading = text.index('Tasks due within five days') overdue_heading = text.index('Tasks past due') @@ -137,7 +137,7 @@ def test_digest_groups_deadline_notifications_into_sections_and_emails_them_only updates_heading = text.index('Task updates') assert_operator text.index("- #{overdue_task.task_definition.abbreviation} -"), :<, updates_heading assert_operator updates_heading, :<, text.index('- Portfolio') - assert_includes html, 'role="separator"' + assert_includes html, '
Date: Thu, 10 Sep 2026 13:28:30 +1000 Subject: [PATCH 47/57] feat: create notifications for tasks past warning feedback threshold --- app/api/notifications_api.rb | 9 +- app/models/notification.rb | 122 ++++++++- app/services/notification_group_builder.rb | 26 +- app/sidekiq/notify_feedback_warnings_job.rb | 36 +++ app/sidekiq/send_notification_digest_job.rb | 2 +- .../notification_digest.html.erb | 8 + .../notification_digest.text.erb | 4 +- config/schedule.yml | 4 + test/api/notifications_api_test.rb | 29 +++ .../feedback_warning_notification_job_test.rb | 243 ++++++++++++++++++ test/sidekiq/scheduled_job_test.rb | 3 +- 11 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 app/sidekiq/notify_feedback_warnings_job.rb create mode 100644 test/sidekiq/feedback_warning_notification_job_test.rb diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index a8540e5516..48c6f050e8 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -27,10 +27,15 @@ def shown_in_app(notifications) end def unread_group_count - rows = current_user.received_notifications.unread.pluck(:id, :task_id, :project_id, :unit_id, :kind, :unit_role_id) - rows.select { |_id, _task_id, _project_id, unit_id, kind, _unit_role_id| notification_settings.shows_in_app?(unit_id, kind) }.map do |id, task_id, project_id, unit_id, kind, unit_role_id| + rows = current_user.received_notifications.unread.pluck( + :id, :task_id, :project_id, :unit_id, :kind, :unit_role_id, :email_sent_at + ) + rows.select { |_id, _task_id, _project_id, unit_id, kind, _unit_role_id, _email_sent_at| notification_settings.shows_in_app?(unit_id, kind) }.map do |id, task_id, project_id, unit_id, kind, unit_role_id, email_sent_at| if Notification::MODERATION_KINDS.include?(kind) "tutor-notes:#{unit_role_id}:#{task_id}" + elsif kind == 'feedback_warning' + batch = email_sent_at ? "emailed:#{email_sent_at.to_f}" : 'not-emailed' + "unit:#{unit_id}:feedback-warning:#{batch}" elsif task_id.present? "task:#{task_id}" elsif Notification::COMMUNICATION_KINDS.include?(kind) diff --git a/app/models/notification.rb b/app/models/notification.rb index 52213eb35a..4c86f17ca2 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'set' + class Notification < ApplicationRecord KINDS = %w[ new_task_comment @@ -7,6 +9,7 @@ class Notification < ApplicationRecord task_start_now task_due_soon task_overdue + feedback_warning overseer_failed pdf_generation_failed discuss_warning @@ -25,6 +28,7 @@ class Notification < ApplicationRecord PORTFOLIO_KINDS = %w[portfolio_ready portfolio_failed].freeze COMMUNICATION_KINDS = %w[communication_email].freeze TASK_DEADLINE_KINDS = %w[task_start_now task_due_soon task_overdue].freeze + FEEDBACK_WARNING_KINDS = %w[feedback_warning].freeze belongs_to :recipient, class_name: 'User', inverse_of: :received_notifications belongs_to :unit @@ -253,6 +257,52 @@ def self.refresh_task_deadline_notifications!(now: Time.current) created end + def self.refresh_feedback_warning_notifications!(now: Time.current, started_at: nil) + stale = where(kind: FEEDBACK_WARNING_KINDS).unread.includes( + :recipient, + task: [ + :task_definition, + { + project: [ + :campus, + { tutorial_enrolments: { tutorial: { unit_role: :user } } }, + { unit: [{ teaching_period: :breaks }, { main_convenor: :user }] } + ] + } + ] + ) + stale.find_each do |notification| + mark_read(where(id: notification.id)) unless notification.current_feedback_warning?(now: now) + end + + candidates = feedback_warning_candidates(now: now) + existing = where(kind: FEEDBACK_WARNING_KINDS, task_id: candidates.select(:id)) + .pluck(:recipient_id, :deduplication_key) + .to_set + + created = 0 + candidates.find_each do |task| + next unless feedback_warning_eligible?(task, now: now, started_at: started_at) + + recipient = feedback_warning_recipient(task) + next if recipient.nil? + + deduplication_key = feedback_warning_key(task) + next if existing.include?([recipient.id, deduplication_key]) + + notification = create_event( + recipient: recipient, + unit: task.unit, + project: task.project, + task: task, + kind: 'feedback_warning', + deduplication_key: deduplication_key + ) + created += 1 if notification&.previously_new_record? + end + created + end + def self.task_deadline_kind(task, now: Time.current) return nil unless task_deadline_eligible?(task, now: now) @@ -272,6 +322,14 @@ def self.task_deadline_key(task, kind) "task-deadline:#{task.id}:#{kind}:#{date.to_date.iso8601}" end + def self.feedback_warning_key(task) + "feedback-warning:#{task.id}:#{task.submission_date.utc.iso8601(6)}" + end + + def self.feedback_warning_status_id + @feedback_warning_status_id ||= TaskStatus.ready_for_feedback.id + end + # Moderation notifications stay unread until their recipient marks the tutor note itself as read. def self.mark_read(relation, at: Time.current, include_moderation: false) relation = relation.where.not(kind: MODERATION_KINDS) unless include_moderation @@ -346,6 +404,19 @@ def current_task_deadline?(now: Time.current) current_kind == kind && deduplication_key == self.class.task_deadline_key(task, kind) end + def current_feedback_warning?(now: Time.current) + return true unless FEEDBACK_WARNING_KINDS.include?(kind) + return false if task.nil? + + self.class.feedback_warning_eligible?(task, now: now) && + self.class.feedback_warning_recipient(task) == recipient && + deduplication_key == self.class.feedback_warning_key(task) + end + + def current_for_delivery?(now: Time.current) + current_task_deadline?(now: now) && current_feedback_warning?(now: now) + end + def self.kind_for_comment(comment) case comment when TaskStatusComment @@ -408,6 +479,38 @@ def self.each_task_deadline_candidate(now:) end end + def self.feedback_warning_recipient(task) + tutorial_enrolment = task.project.tutorial_enrolments.find do |enrolment| + tutorial_stream_id = enrolment.tutorial.tutorial_stream_id + tutorial_stream_id.nil? || tutorial_stream_id == task.task_definition.tutorial_stream_id + end + + tutorial_enrolment&.tutorial&.unit_role&.user || task.unit.main_convenor_user + end + + def self.feedback_warning_candidates(now:) + Task + .joins(:task_definition, project: :unit) + .where(projects: { enrolled: true }) + .where(units: { active: true, send_notifications: true }) + .where('units.start_date <= :now AND units.end_date >= :now', now: now) + .where(task_status_id: feedback_warning_status_id) + .where.not(submission_date: nil) + .where( + 'TIMESTAMPDIFF(DAY, tasks.submission_date, :now) >= units.feedback_warning_threshold_days', + now: now + ) + .where('projects.target_grade >= task_definitions.target_grade') + .preload( + :task_definition, + project: [ + :campus, + { tutorial_enrolments: { tutorial: { unit_role: :user } } }, + { unit: [{ teaching_period: :breaks }, { main_convenor: :user }] } + ] + ) + end + def self.task_deadline_eligible?(task, now:) task.project.enrolled && task.unit.active && @@ -419,6 +522,23 @@ def self.task_deadline_eligible?(task, now:) !task.submitted_status? end + def self.feedback_warning_eligible?(task, now:, started_at: nil) + eligible = + task.project.enrolled && + task.unit.active && + task.unit.send_notifications && + task.unit.start_date <= now && + task.unit.end_date >= now && + task.task_definition.target_grade <= task.project.target_grade && + task.task_status_id == feedback_warning_status_id && + task.submission_date.present? && + task.days_awaiting_feedback(now) >= task.unit.feedback_warning_threshold_days + return false unless eligible + return true if started_at.nil? || task.submission_date > started_at + + task.days_awaiting_feedback(started_at) < task.unit.feedback_warning_threshold_days + end + private_class_method :kind_for_comment, :discuss_deadline_for, :recipients_for_comment, :student_actor?, - :each_task_deadline_candidate, :task_deadline_eligible? + :each_task_deadline_candidate, :task_deadline_eligible?, :feedback_warning_candidates end diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index f757655ab2..fbb05e0c33 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -34,6 +34,9 @@ def grouping_key(notification) # A moderation note belongs to one staff member's thread, so it groups by that # rather than joining the task's other notifications. return "#{state_key}:tutor-notes:#{notification.unit_role_id}:#{notification.task_id}" if Notification::MODERATION_KINDS.include?(notification.kind) + if notification.kind == 'feedback_warning' + return "#{state_key}:unit:#{notification.unit_id}:feedback-warning:#{feedback_warning_batch(notification)}" + end return "#{state_key}:task:#{notification.task_id}" if notification.task_id.present? return "#{state_key}:portfolio:#{notification.project_id}" if Notification::PORTFOLIO_KINDS.include?(notification.kind) @@ -43,8 +46,9 @@ def grouping_key(notification) def build_group(items) latest = items.max_by(&:created_at) - task = latest.task counts = items.each_with_object(Hash.new(0)) { |notification, result| result[notification.kind] += 1 } + feedback_warning = counts['feedback_warning'].positive? + task = feedback_warning ? nil : latest.task latest_status = items .select { |notification| notification.kind == 'task_status_changed' } .max_by(&:created_at) @@ -54,7 +58,7 @@ def build_group(items) .select { |notification| Notification::MODERATION_KINDS.include?(notification.kind) } .sort_by { |notification| [notification.created_at, notification.id] } detail = detail_for(items, counts, latest_status) - project = latest.project || task&.project + project = feedback_warning ? nil : (latest.project || task&.project) { key: grouping_key(latest), @@ -65,8 +69,9 @@ def build_group(items) code: latest.unit.code, name: latest.unit.name }, - project_id: latest.project_id, + project_id: project&.id, task: task_details(task, latest.recipient), + destination: feedback_warning ? { type: 'unit_inbox', unit_id: latest.unit_id } : nil, counts: counts, event_count: items.count, latest_status: latest_status, @@ -86,6 +91,10 @@ def build_group(items) } end + def feedback_warning_batch(notification) + notification.email_sent_at ? "emailed:#{notification.email_sent_at.to_f}" : 'not-emailed' + end + # The task's Mod Notes tab shows the notes on its own tutor, so a note about # anyone else - a convenor's thread that happens to name this task - has to be # opened as a thread instead. @@ -115,7 +124,7 @@ def task_details(task, recipient) def severity_for(items) kinds = items.map(&:kind) return 'critical' if kinds.intersect?(%w[discuss_expired pdf_generation_failed portfolio_failed task_overdue]) - return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed task_due_soon] + Notification::MODERATION_KINDS) + return 'warning' if kinds.intersect?(%w[discuss_warning overseer_failed task_due_soon feedback_warning] + Notification::MODERATION_KINDS) 'normal' end @@ -129,6 +138,7 @@ def overseer_assessment_id(items) def subject_for(task, recipient, counts, latest) return latest.message_subject.presence || 'Email message' if counts['communication_email'].positive? return 'Portfolio' if Notification::PORTFOLIO_KINDS.any? { |kind| counts[kind].positive? } + return 'Feedback required' if counts['feedback_warning'].positive? return 'Unit notification' if task.nil? return task.task_definition.abbreviation if task.project.student == recipient @@ -141,6 +151,7 @@ def subject_for(task, recipient, counts, latest) def detail_for(items, counts, latest_status) details = [ communication_detail(items, counts), + feedback_warning_detail(counts), *event_details(counts), moderation_detail(items), comment_detail(counts), @@ -152,6 +163,13 @@ def detail_for(items, counts, latest_status) details.to_sentence.upcase_first end + def feedback_warning_detail(counts) + count = counts['feedback_warning'] + return unless count.positive? + + "You have #{pluralize(count, 'task')} that #{count == 1 ? 'requires' : 'require'} feedback as soon as possible" + end + def communication_detail(items, counts) return unless counts['communication_email'].positive? diff --git a/app/sidekiq/notify_feedback_warnings_job.rb b/app/sidekiq/notify_feedback_warnings_job.rb new file mode 100644 index 0000000000..dfdbac0eb7 --- /dev/null +++ b/app/sidekiq/notify_feedback_warnings_job.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class NotifyFeedbackWarningsJob + include Sidekiq::Job + + ROLLOUT_KEY = 'notifications:feedback_warning:rollout_at' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['notify-feedback-warnings'] }, + on_conflict: :reject, + retry: 1 + + def perform + now = Time.current + # Avoid backfilling notifications for tasks that were already overdue when this ships + # The first run stores a one-off rollout time so we only pick up tasks that cross the threshold after started_at + started_at = rollout_started_at + if started_at.nil? + store_rollout_started_at(now) + return + end + + Notification.refresh_feedback_warning_notifications!(now: now, started_at: started_at) + end + + private + + def rollout_started_at + value = Sidekiq.redis { |redis| redis.get(ROLLOUT_KEY) } + Time.zone.parse(value) if value.present? + end + + def store_rollout_started_at(time) + Sidekiq.redis { |redis| redis.set(ROLLOUT_KEY, time.utc.iso8601(6), nx: true) } + end +end diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index 7382bf5ae7..e685c022de 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -35,7 +35,7 @@ def perform(setting_id) def deliverable?(setting, notification, now) notification.unit.send_notifications && !notification.recipient_withdrawn? && - notification.current_task_deadline?(now: now) && + notification.current_for_delivery?(now: now) && setting.delivers?(notification.unit, notification.kind, :email) end diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index 1d0ed757f1..d0fb1bd292 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -60,6 +60,8 @@
<% if group[:task] %> <%= group[:task][:abbreviation] %> - <%= group[:task][:name] %> + <% elsif group.dig(:destination, :type) == 'unit_inbox' %> + Feedback required <% elsif group[:message_subject].present? %> <%= group[:message_subject] %> <% elsif group[:project_id] %> @@ -89,6 +91,12 @@ View this task
+ <% elsif group.dig(:destination, :type) == 'unit_inbox' %> + <% elsif group[:project_id] %>
diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index ef72612c73..bc67424c5b 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -11,7 +11,7 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <%= group_section[:heading] %> <% end %> <% group_section[:groups].each do |group| %> -- <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group[:message_subject].present? %><%= group[:message_subject] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %><% if group_section[:kind] && group.dig(:task, :due_date) %> — <%= notification_due_date(group) %><% end %> +- <% if group[:task] %><%= group[:task][:abbreviation] %> - <%= group[:task][:name] %><% elsif group.dig(:destination, :type) == 'unit_inbox' %>Feedback required<% elsif group[:message_subject].present? %><%= group[:message_subject] %><% elsif group[:project_id] %>Portfolio<% else %>Unit notification<% end %><% if group_section[:kind] && group.dig(:task, :due_date) %> — <%= notification_due_date(group) %><% end %> <% unless group_section[:kind] %> <%= notification_timestamp(group) %> <% end %> @@ -20,6 +20,8 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <% end %> <% if group[:task] %> <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> +<% elsif group.dig(:destination, :type) == 'unit_inbox' %> + <%= @doubtfire_host %>/units/<%= group.dig(:destination, :unit_id) %>/tasks/inbox <% elsif group[:project_id] %> <%= @doubtfire_host %>/projects/<%= group[:project_id] %>/portfolio <% end %> diff --git a/config/schedule.yml b/config/schedule.yml index 18af2b0abd..6f6904f9e5 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -32,6 +32,10 @@ notify_task_deadlines: cron: "every hour" class: "NotifyTaskDeadlinesJob" +notify_feedback_warnings: + cron: "every hour" + class: "NotifyFeedbackWarningsJob" + poll_notification_digests: cron: "every 5 minutes" class: "PollNotificationDigestsJob" diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index f204ca014e..df7be489bd 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -42,6 +42,34 @@ def test_unread_count_counts_a_task_group_instead_of_each_event assert_equal 1, last_response_body['count'] end + def test_feedback_warnings_group_by_unit_and_email_delivery_batch + @student.received_notifications.destroy_all + delivered_at = Time.current.change(usec: 0) + 2.times do + FactoryBot.create( + :notification, + recipient: @student, + unit: @unit, + kind: 'feedback_warning', + email_processed_at: delivered_at, + email_sent_at: delivered_at + ) + end + FactoryBot.create( + :notification, + recipient: @student, + unit: @unit, + kind: 'feedback_warning' + ) + + get '/api/notifications', state: 'unread' + + assert_equal 2, last_response_body['unread_count'] + assert_equal [1, 2], last_response_body['groups'].map { |group| group.dig('counts', 'feedback_warning') }.sort + assert_equal [{ 'type' => 'unit_inbox', 'unit_id' => @unit.id }], + last_response_body['groups'].pluck('destination').uniq + end + def test_portfolio_notifications_are_grouped_by_project @student.received_notifications.destroy_all Notification.create_for_portfolio(@project, success: true) @@ -178,6 +206,7 @@ def test_settings_start_from_the_defaults assert_equal '07:00', last_response_body['digest_start_time'] assert_equal @project.campus.timezone, last_response_body['digest_timezone'] assert_equal %w[in_app email], last_response_body.dig('channels', 'new_task_comment') + assert_equal %w[in_app email], last_response_body.dig('channels', 'feedback_warning') assert_empty last_response_body['units'] end diff --git a/test/sidekiq/feedback_warning_notification_job_test.rb b/test/sidekiq/feedback_warning_notification_job_test.rb new file mode 100644 index 0000000000..f8a1bc6366 --- /dev/null +++ b/test/sidekiq/feedback_warning_notification_job_test.rb @@ -0,0 +1,243 @@ +require 'test_helper' + +class FeedbackWarningNotificationJobTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + + class FakeRedis + attr_reader :store + + def initialize + @store = {} + end + + def get(key) + store[key] + end + + def set(key, value, **options) + return if options[:nx] && store.key?(key) + + store[key] = value + 'OK' + end + end + + def setup + @now = Time.zone.local(2026, 9, 7, 9) + teaching_period = FactoryBot.create( + :teaching_period, + start_date: @now - 2.weeks, + end_date: @now + 2.months, + active_until: @now + 3.months + ) + @unit = FactoryBot.create( + :unit, + teaching_period: teaching_period, + task_count: 1, + feedback_warning_threshold_days: 5 + ) + definition = @unit.task_definitions.first + @project = @unit.active_projects.find do |project| + project.target_grade >= definition.target_grade && project.tutorial_for(definition).present? + end + @task = @project.task_for_task_definition(definition) + @tutor = FactoryBot.create(:user, :tutor) + @tutor_role = @unit.employ_staff(@tutor, Role.tutor) + @project.tutorial_for(@task.task_definition).update!(unit_role: @tutor_role) + @task.update!( + task_status: TaskStatus.ready_for_feedback, + submission_date: @now - 5.days + ) + end + + def test_creates_one_warning_for_the_assigned_tutor_at_the_threshold + 2.times { Notification.refresh_feedback_warning_notifications!(now: @now) } + + notifications = Notification.where(task: @task, kind: 'feedback_warning') + assert_equal 1, notifications.count + assert_equal @tutor, notifications.first.recipient + end + + def test_does_not_warn_before_the_threshold + @task.update!(submission_date: @now - 5.days + 1.minute) + + Notification.refresh_feedback_warning_notifications!(now: @now) + + assert_not Notification.exists?(task: @task, kind: 'feedback_warning') + end + + def test_teaching_breaks_pause_the_warning_clock + @unit.teaching_period.add_break(@now - 4.days, 7) + + Notification.refresh_feedback_warning_notifications!(now: @now) + + assert_not Notification.exists?(task: @task, kind: 'feedback_warning') + end + + def test_falls_back_to_the_main_convenor_when_no_tutor_is_assigned + @project.tutorial_for(@task.task_definition).update!(unit_role: nil) + + Notification.refresh_feedback_warning_notifications!(now: @now) + + assert_equal @unit.main_convenor_user, + Notification.find_by!(task: @task, kind: 'feedback_warning').recipient + end + + def test_resolves_a_warning_when_the_task_no_longer_needs_feedback + Notification.refresh_feedback_warning_notifications!(now: @now) + notification = Notification.find_by!(task: @task, kind: 'feedback_warning') + @task.update!(task_status: TaskStatus.complete) + + Notification.refresh_feedback_warning_notifications!(now: @now + 1.hour) + + assert_not_nil notification.reload.read_at + assert_not_nil notification.email_processed_at + end + + def test_reassignment_resolves_the_old_warning_and_notifies_the_new_tutor + Notification.refresh_feedback_warning_notifications!(now: @now) + original = Notification.find_by!(task: @task, kind: 'feedback_warning') + replacement = FactoryBot.create(:user, :tutor) + replacement_role = @unit.employ_staff(replacement, Role.tutor) + @project.tutorial_for(@task.task_definition).update!(unit_role: replacement_role) + + Notification.refresh_feedback_warning_notifications!(now: @now + 1.hour) + + assert_not_nil original.reload.read_at + assert Notification.exists?(task: @task, kind: 'feedback_warning', recipient: replacement, read_at: nil) + end + + def test_withdrawal_threshold_and_unit_state_changes_resolve_warnings + assert_resolves_warning { @project.update!(enrolled: false) } + reset_warning(submission_date: @now - 6.days) + assert_resolves_warning { @unit.update!(feedback_warning_threshold_days: 7) } + @unit.update!(feedback_warning_threshold_days: 5) + reset_warning(submission_date: @now - 8.days) + assert_resolves_warning { @unit.update!(active: false) } + end + + def test_a_resubmission_can_raise_another_warning + Notification.refresh_feedback_warning_notifications!(now: @now) + first = Notification.find_by!(task: @task, kind: 'feedback_warning') + @task.update!(task_status: TaskStatus.complete) + Notification.refresh_feedback_warning_notifications!(now: @now + 1.hour) + @task.update!( + task_status: TaskStatus.ready_for_feedback, + submission_date: @now + 2.days + ) + + Notification.refresh_feedback_warning_notifications!(now: @now + 7.days) + + warnings = Notification.where(task: @task, kind: 'feedback_warning').order(:id) + assert_equal 2, warnings.count + assert_equal first, warnings.first + assert_nil warnings.last.read_at + end + + def test_groups_pending_tasks_then_starts_a_new_group_after_email_delivery + create_warning_task(submitted_at: @now - 6.days) + Notification.refresh_feedback_warning_notifications!(now: @now) + settings = NotificationSetting.for(@tutor) + + assert_emails 1 do + SendNotificationDigestJob.new.perform(settings.id) + end + + delivered = Notification.where(recipient: @tutor, kind: 'feedback_warning') + assert_equal 2, delivered.count + assert(delivered.all? { |notification| notification.email_sent_at.present? }) + assert_includes ActionMailer::Base.deliveries.last.text_part.body.to_s, '2 tasks that require feedback' + assert_includes ActionMailer::Base.deliveries.last.text_part.body.to_s, + "/units/#{@unit.id}/tasks/inbox" + + create_warning_task(submitted_at: @now - 7.days) + Notification.refresh_feedback_warning_notifications!(now: @now + 1.hour) + groups = NotificationGroupBuilder.new( + Notification.where(recipient: @tutor, kind: 'feedback_warning').unread + ).groups + + assert_equal [1, 2], groups.map { |group| group[:counts]['feedback_warning'] }.sort + pending = groups.find { |group| group[:counts]['feedback_warning'] == 1 } + assert_equal({ type: 'unit_inbox', unit_id: @unit.id }, pending[:destination]) + assert_equal 'warning', pending[:severity] + end + + def test_disabled_channels_prevent_creation + settings = NotificationSetting.for(@tutor) + settings.update!(channels: settings.channels.merge('feedback_warning' => [])) + + Notification.refresh_feedback_warning_notifications!(now: @now) + + assert_not Notification.exists?(task: @task, kind: 'feedback_warning') + end + + def test_first_job_run_only_sets_the_rollout_boundary + Notification.refresh_feedback_warning_notifications!(now: @now) + existing = Notification.find_by!(task: @task, kind: 'feedback_warning') + redis = FakeRedis.new + + with_sidekiq_redis(redis) do + travel_to(@now) { NotifyFeedbackWarningsJob.new.perform } + travel_to(@now + 1.hour) { NotifyFeedbackWarningsJob.new.perform } + end + + assert_nil existing.reload.read_at + assert_nil existing.email_processed_at + assert_equal 1, Notification.where(task: @task, kind: 'feedback_warning').count + assert redis.store.key?(NotifyFeedbackWarningsJob::ROLLOUT_KEY) + end + + def test_job_notifies_an_existing_submission_only_when_it_crosses_after_rollout + @task.update!(submission_date: @now - 4.days) + redis = FakeRedis.new + + with_sidekiq_redis(redis) do + travel_to(@now) { NotifyFeedbackWarningsJob.new.perform } + assert_not Notification.exists?(task: @task, kind: 'feedback_warning') + + travel_to(@now + 1.day) { NotifyFeedbackWarningsJob.new.perform } + end + + assert Notification.exists?(task: @task, kind: 'feedback_warning', recipient: @tutor) + end + + private + + def create_warning_task(submitted_at:) + definition = FactoryBot.create( + :task_definition, + unit: @unit, + tutorial_stream: @task.task_definition.tutorial_stream, + target_grade: @task.task_definition.target_grade + ) + FactoryBot.create( + :task, + project: @project, + task_definition: definition, + task_status: TaskStatus.ready_for_feedback, + submission_date: submitted_at + ) + end + + def reset_warning(submission_date:) + @project.update!(enrolled: true) + @task.update!(task_status: TaskStatus.ready_for_feedback, submission_date: submission_date) + Notification.refresh_feedback_warning_notifications!(now: @now) + end + + def assert_resolves_warning + Notification.refresh_feedback_warning_notifications!(now: @now) + notification = Notification.where(task: @task, kind: 'feedback_warning').unread.first! + yield + Notification.refresh_feedback_warning_notifications!(now: @now + 1.hour) + assert_not_nil notification.reload.read_at + end + + def with_sidekiq_redis(redis) + original = Sidekiq.method(:redis) + Sidekiq.define_singleton_method(:redis) { |&redis_block| redis_block.call(redis) } + yield + ensure + Sidekiq.define_singleton_method(:redis, original) + end +end diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index 37de9eb3fd..92bd5de787 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,7 +6,7 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.destroy_all! Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 10, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 11, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count @@ -17,6 +17,7 @@ def test_jobs_are_scheduled assert_equal 1, PollCommunicationSetSchedulesJob.jobs.count assert_equal 1, NotifyDiscussTimeoutJob.jobs.count assert_equal 1, NotifyTaskDeadlinesJob.jobs.count + assert_equal 1, NotifyFeedbackWarningsJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end From 440abeaa0f58100a91e4050911f3fee8be7d75c9 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:41:24 +1000 Subject: [PATCH 48/57] feat: replace weekly summary emails into notifications --- app/api/notifications_api.rb | 62 ++++--- app/mailers/notifications_mailer.rb | 114 +------------ app/models/notification.rb | 24 +++ app/models/notification_setting.rb | 25 ++- app/models/project.rb | 13 +- app/models/unit.rb | 40 +++-- app/models/unit_role.rb | 25 +-- app/services/notification_group_builder.rb | 22 ++- .../weekly_summary_notification_builder.rb | 122 +++++++++++++ ...create_weekly_summary_notifications_job.rb | 71 ++++++++ app/sidekiq/poll_notification_digests_job.rb | 3 + .../notification_digest.html.erb | 103 +++++++++++ .../notification_digest.text.erb | 51 ++++++ .../weekly_staff_summary.html.erb | 160 ------------------ .../weekly_staff_summary.text.erb | 70 -------- .../weekly_student_summary.html.erb | 118 ------------- .../weekly_student_summary.text.erb | 67 -------- lib/tasks/send_status_emails.rake | 14 +- test/api/notifications_api_test.rb | 42 +++++ test/mailers/unit_mail_test.rb | 28 ++- test/models/notification_setting_test.rb | 24 +++ test/sidekiq/notification_jobs_test.rb | 93 ++++++++++ .../weekly_summary_notification_job_test.rb | 52 ++++++ 23 files changed, 745 insertions(+), 598 deletions(-) create mode 100644 app/services/weekly_summary_notification_builder.rb create mode 100644 app/sidekiq/create_weekly_summary_notifications_job.rb delete mode 100644 app/views/notifications_mailer/weekly_staff_summary.html.erb delete mode 100644 app/views/notifications_mailer/weekly_staff_summary.text.erb delete mode 100644 app/views/notifications_mailer/weekly_student_summary.html.erb delete mode 100644 app/views/notifications_mailer/weekly_student_summary.text.erb create mode 100644 test/sidekiq/weekly_summary_notification_job_test.rb diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb index 48c6f050e8..23447feb76 100644 --- a/app/api/notifications_api.rb +++ b/app/api/notifications_api.rb @@ -26,26 +26,42 @@ def shown_in_app(notifications) notifications.select { |notification| notification_settings.shows_in_app?(notification.unit_id, notification.kind) } end - def unread_group_count - rows = current_user.received_notifications.unread.pluck( + def unread_group_keys_by_unit + @unread_group_keys_by_unit ||= current_user.received_notifications.unread.pluck( :id, :task_id, :project_id, :unit_id, :kind, :unit_role_id, :email_sent_at - ) - rows.select { |_id, _task_id, _project_id, unit_id, kind, _unit_role_id, _email_sent_at| notification_settings.shows_in_app?(unit_id, kind) }.map do |id, task_id, project_id, unit_id, kind, unit_role_id, email_sent_at| - if Notification::MODERATION_KINDS.include?(kind) - "tutor-notes:#{unit_role_id}:#{task_id}" - elsif kind == 'feedback_warning' - batch = email_sent_at ? "emailed:#{email_sent_at.to_f}" : 'not-emailed' - "unit:#{unit_id}:feedback-warning:#{batch}" - elsif task_id.present? - "task:#{task_id}" - elsif Notification::COMMUNICATION_KINDS.include?(kind) - "communication-email:#{id}" - elsif Notification::PORTFOLIO_KINDS.include?(kind) - "portfolio:#{project_id}" - else - "unit:#{unit_id}:#{kind}" - end - end.uniq.count + ).filter_map do |id, task_id, project_id, unit_id, kind, unit_role_id, email_sent_at| + next unless notification_settings.shows_in_app?(unit_id, kind) + + key = + if Notification::MODERATION_KINDS.include?(kind) + "tutor-notes:#{unit_role_id}:#{task_id}" + elsif kind == 'feedback_warning' + batch = email_sent_at ? "emailed:#{email_sent_at.to_f}" : 'not-emailed' + "unit:#{unit_id}:feedback-warning:#{batch}" + elsif kind == 'weekly_summary' + "weekly-summary:#{id}" + elsif task_id.present? + "task:#{task_id}" + elsif Notification::COMMUNICATION_KINDS.include?(kind) + "communication-email:#{id}" + elsif Notification::PORTFOLIO_KINDS.include?(kind) + "portfolio:#{project_id}" + else + "unit:#{unit_id}:#{kind}" + end + + [unit_id, key] + end.uniq + end + + def unread_group_count + unread_group_keys_by_unit.count + end + + def unread_group_counts_by_unit + unread_group_keys_by_unit.each_with_object(Hash.new(0)) do |(unit_id, _key), counts| + counts[unit_id] += 1 + end end # The units sent are the whole set that departs from the settings, so any unit @@ -105,7 +121,8 @@ def accessible_unit_ids group.dig(:task, :name), group.dig(:task, :student_name), group[:message_subject], - group[:message_body] + group[:message_body], + group[:weekly_summary]&.to_json ].compact.any? { |value| value.to_s.downcase.include?(query) } end end @@ -119,13 +136,14 @@ def accessible_unit_ids page: page, per_page: per_page, total: total, - unread_count: unread_group_count + unread_count: unread_group_count, + unread_counts_by_unit: unread_group_counts_by_unit } end desc 'Get the grouped unread notification count for the current user' get '/notifications/unread_count' do - { count: unread_group_count } + { count: unread_group_count, unread_counts_by_unit: unread_group_counts_by_unit } end desc 'Mark selected notifications as read' diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index f3bc00c774..f81c930af8 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -14,81 +14,6 @@ def add_general @unsubscribe_url = "#{@doubtfire_host}/notifications/settings" end - def weekly_staff_summary(unit_role, summary_stats) - return nil if unit_role.nil? - - add_general - - @staff = unit_role.user - @unit_role = unit_role - @unit = summary_stats[:unit] - - @received_comments = @unit.comments - .where("task_comments.recipient_id = :uid AND task_comments.created_at > :start", uid: @staff.id, start: 7.days.ago) - .where(content_type: [:text, :assessment, :audio, :image, :pdf, :discussion, :extension]) - .count - - @sent_comments = @unit.comments - .where("task_comments.user_id = :uid AND task_comments.created_at > :start", uid: @staff.id, start: 7.days.ago) - .where(content_type: [:text, :assessment, :audio, :image, :pdf, :discussion, :extension]) - .count - - @data = { - sent_comments: @sent_comments, # Sent by tutor - received_comments: @received_comments, # Received by tutor - tasks_awaiting_feedback_count: summary_stats[:staff][unit_role.user][:tasks_awaiting_feedback_count], # For the tutor - weekly_engagements_count: summary_stats[:staff][unit_role.user][:weekly_engagements_count], # Engagements from the student? - staff_engagements: summary_stats[:staff][unit_role.user][:staff_engagements], # Engagements by the tutor - oldest_task_days: summary_stats[:staff][unit_role.user][:oldest_task_days], - weekly_total_tasks_discussed: summary_stats[:staff][unit_role.user][:weekly_total_tasks_discussed] # Total for the tutor for the week for the tutor - } - - @convenor = @unit.main_convenor_user - @summary_stats = summary_stats - - email_with_name = %("#{@staff.name}" <#{@staff.email}>) - convenor_email = %("#{@convenor.name}" <#{@convenor.email}>) - subject = "#{@unit.name}: Weekly Summary" - - mail(to: email_with_name, from: convenor_email, subject: subject) - end - - def weekly_student_summary(project, summary_stats, did_revert_to_pass) - return nil if project.nil? - - add_general - - @student = project.student - @project = project - @tutor = project.main_convenor_user - @summary_stats = summary_stats - @did_revert_to_pass = did_revert_to_pass - - @engagements = @project.task_engagements.where("task_engagements.engagement_time >= :start AND task_engagements.engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]) - - @engagements_count = @engagements.count - - @student_engagements = @engagements.select { |e| [TaskStatus.not_started.name, TaskStatus.need_help.name, TaskStatus.working_on_it.name, TaskStatus.ready_for_feedback.name].include? e.engagement }.count - - @staff_engagements = @engagements.select { |e| [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.rediscuss.name, TaskStatus.attention_required.name, TaskStatus.demonstrate.name, TaskStatus.fail.name].include? e.engagement }.count - - @task_states = project.tasks.joins(:task_status).select("count(tasks.id) as number, task_statuses.name as status").group("task_statuses.name") - - @received_comments = project.comments.where("recipient_id = :student_id AND task_comments.created_at > :start", student_id: @student.id, start: Time.zone.now - 7.days).count - @sent_comments = project.comments.where("user_id = :student_id AND task_comments.created_at > :start", student_id: @student.id, start: Time.zone.now - 7.days).count - - @top_tasks = project.top_tasks - @overdue_top = @top_tasks.select { |tt| tt[:reason] == :overdue } - @soon_top = @top_tasks.select { |tt| tt[:reason] == :soon } - @ahead_top = @top_tasks.select { |tt| tt[:reason] == :ahead } - - email_with_name = %("#{@student.name}" <#{@student.email}>) - tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) - subject = "#{project.unit.name}: Weekly Summary" - - mail(to: email_with_name, from: tutor_email, subject: subject) - end - def notification_digest(recipient, notifications) return nil if recipient.nil? || notifications.blank? @@ -158,43 +83,18 @@ def discussion_deadline_missed(task, sender) ) end - def top_task_desc(tt) - "#{tt[:task_definition].abbreviation} - #{tt[:task_definition].name} #{"- which you need to discuss with your tutor" if tt[:status] == :discuss}" - end - - def were_was(num) - if num == 1 - "was" - else - "were" - end - end - - def are_is(num) - if num == 1 - "is" - else - "are" - end - end - - def this_these(num) - if num == 1 - "this" - else - "these" - end - end - - helper_method :top_task_desc - helper_method :were_was - helper_method :are_is - helper_method :this_these helper_method :notification_timestamp helper_method :notification_due_date + helper_method :weekly_summary_period private + def weekly_summary_period(summary) + from = Time.zone.parse(summary.fetch('week_start')) + to = Time.zone.parse(summary.fetch('week_end')) + "#{from.day.ordinalize} #{from.strftime('%B')} to #{to.day.ordinalize} #{to.strftime('%B %Y')}" + end + def add_task_deadline_due_dates!(groups, notifications) due_dates = notifications.filter_map do |notification| next unless Notification::TASK_DEADLINE_KINDS.include?(notification.kind) && notification.task.present? diff --git a/app/models/notification.rb b/app/models/notification.rb index 4c86f17ca2..d5665ff366 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -10,6 +10,7 @@ class Notification < ApplicationRecord task_due_soon task_overdue feedback_warning + weekly_summary overseer_failed pdf_generation_failed discuss_warning @@ -189,6 +190,21 @@ def self.create_for_communication_email( ) end + def self.create_weekly_summary(recipient:, unit:, data:, project: nil) + week_start = Time.zone.parse(data.fetch(:week_start).to_s).to_date.iso8601 + audience = data.fetch(:audience) + create_event( + recipient: recipient, + unit: unit, + project: project, + actor: unit.main_convenor_user, + kind: 'weekly_summary', + message_subject: "#{unit.code}: Weekly summary", + message_body: JSON.generate(data), + deduplication_key: "weekly-summary:#{unit.id}:#{audience}:#{week_start}" + ) + end + def self.create_event(**attributes) recipient = attributes.fetch(:recipient) unit = attributes.fetch(:unit) @@ -392,6 +408,14 @@ def recipient_withdrawn? self.class.withdrawn_student?(project, recipient) end + def weekly_summary_data + return unless kind == 'weekly_summary' && message_body.present? + + JSON.parse(message_body) + rescue JSON::ParserError + nil + end + def email_ready?(at: Time.current) email_not_before.blank? || email_not_before <= at end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 991aacb316..6d5508b299 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -33,7 +33,13 @@ def self.for(user) def self.default_channels Notification::KINDS.index_with do |kind| - Notification::COMMUNICATION_KINDS.include?(kind) ? ['in_app'] : %w[in_app email] + if kind == 'weekly_summary' + [] + elsif Notification::COMMUNICATION_KINDS.include?(kind) + ['in_app'] + else + %w[in_app email] + end end end @@ -66,7 +72,22 @@ def delivers?(unit, kind, channel) def weekly_summary_for?(unit) override = user.notification_unit_overrides.find_by(unit_id: unit&.id) - !override&.muted + return false if override&.muted + + Array((override&.customised? ? override.channels : channels)['weekly_summary']).any? + end + + def weekly_summary_opted_in? + return true if Array(channels['weekly_summary']).any? + + user.notification_unit_overrides.any? do |override| + !override.muted && override.customised? && Array(override.channels['weekly_summary']).any? + end + end + + def weekly_summary_due?(at = Time.current) + local = at.in_time_zone(digest_timezone) + local.monday? && local.hour == 7 && local.min < 15 end # Notifications are always recorded so the digest has something to send, so the diff --git a/app/models/project.rb b/app/models/project.rb index 010404e101..ac87db2a61 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -678,7 +678,7 @@ def group_membership_for_groupset(gs) group_memberships.joins(:group).where('groups.group_set_id = :id', id: gs).first end - def send_weekly_status_email(summary_stats, middle_of_unit) + def create_weekly_summary_notification(summary_stats, middle_of_unit) did_revert_to_pass = false # TODO: refactor automatic target grade reset # if middle_of_unit && should_revert_to_pass && !portfolio_exists? @@ -693,11 +693,12 @@ def send_weekly_status_email(summary_stats, middle_of_unit) return unless NotificationSetting.for(student).weekly_summary_for?(unit) return if portfolio_exists? && !middle_of_unit - begin - NotificationsMailer.weekly_student_summary(self, summary_stats, did_revert_to_pass).deliver_now - rescue StandardError => e - logger.error "Failed to send weekly status email for project #{id}!\n#{e.message}" - end + data = WeeklySummaryNotificationBuilder.for_student( + self, + summary_stats, + did_revert_to_pass: did_revert_to_pass + ) + Notification.create_weekly_summary(recipient: student, unit: unit, project: self, data: data) end def archive_submissions(out) diff --git a/app/models/unit.rb b/app/models/unit.rb index 24ddcf1c3d..b6ca695ea2 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -3699,9 +3699,14 @@ def upload_batch_feedback_zip(user, task_definition, file, progress_callback: ni repacked_zip.close! if defined?(repacked_zip) && repacked_zip.present? end - def send_weekly_status_emails(summary_stats) + def create_weekly_summary_notifications(summary_stats, recipient_ids: nil) return unless send_notifications + summary_recipients = recipient_ids.presence + student_projects = summary_recipients ? active_projects.where(user_id: summary_recipients) : active_projects + staff_recipients = summary_recipients ? staff.where(user_id: summary_recipients) : staff + build_staff_summaries = staff_recipients.exists? + summary_stats[:unit] = self summary_stats[:tutorials] = {} summary_stats[:tutorial_streams] = {} @@ -3718,25 +3723,30 @@ def send_weekly_status_emails(summary_stats) summary_stats[:unit_week_engagements] = task_engagements.where("task_engagements.engagement_time > :start AND task_engagements.engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count - days_to_end_of_unit = (end_date.to_date - DateTime.now).to_i - days_from_start_of_unit = (DateTime.now - start_date.to_date).to_i + summary_date = summary_stats[:week_end].to_date + days_to_end_of_unit = (end_date.to_date - summary_date).to_i + days_from_start_of_unit = (summary_date - start_date.to_date).to_i return if days_from_start_of_unit < 4 || days_to_end_of_unit < 0 - staff.each do |ur| - summary_stats[:staff][ur.user] ||= {} - summary_stats[:staff][ur.user][:staff_engagements] ||= 0 - summary_stats[:staff][ur.user][:tasks_awaiting_feedback_count] ||= 0 - summary_stats[:staff][ur.user][:weekly_engagements_count] ||= 0 - summary_stats[:staff][ur.user][:weekly_total_tasks_discussed] ||= 0 - summary_stats[:staff][ur.user][:oldest_task_days] ||= 0 - summary_stats[:revert][ur.user] = [] + if build_staff_summaries + staff.each do |ur| + summary_stats[:staff][ur.user] ||= {} + summary_stats[:staff][ur.user][:staff_engagements] ||= 0 + summary_stats[:staff][ur.user][:tasks_awaiting_feedback_count] ||= 0 + summary_stats[:staff][ur.user][:weekly_engagements_count] ||= 0 + summary_stats[:staff][ur.user][:weekly_total_tasks_discussed] ||= 0 + summary_stats[:staff][ur.user][:oldest_task_days] ||= 0 + summary_stats[:revert][ur.user] = [] + end end - active_projects.each do |project| - project.send_weekly_status_email(summary_stats, days_from_start_of_unit > 28 && days_to_end_of_unit > 14) + student_projects.each do |project| + project.create_weekly_summary_notification(summary_stats, days_from_start_of_unit > 28 && days_to_end_of_unit > 14) end + return unless build_staff_summaries + tutorial_streams.each do |tutorial_stream| summary_stats[:tutorial_streams][tutorial_stream] ||= {} @@ -3773,8 +3783,8 @@ def send_weekly_status_emails(summary_stats) # Group tutorials by tutor group_tutorials_by_tutor(summary_stats) - staff.each do |ur| - ur.send_weekly_status_email(summary_stats) + staff_recipients.each do |ur| + ur.create_weekly_summary_notification(summary_stats) end summary_stats[:staff] = {} diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index 65754c6e67..bcd98ca7f4 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -174,17 +174,23 @@ def populate_summary_stats(summary_stats, tutorial_stream, tutorial, row) .where(content_type: :discussed_in_class) data[:weekly_tasks_discussed] = data[:total_tasks_discussed] - .where("task_comments.created_at > :start", start: Time.zone.now - 7.days) + .where( + "task_comments.created_at >= :start AND task_comments.created_at < :end", + start: summary_stats[:week_start], + end: summary_stats[:week_end] + ) data[:received_comments] = total_comments - .where("recipient_id = :staff_id AND task_comments.created_at > :start", + .where("recipient_id = :staff_id AND task_comments.created_at >= :start AND task_comments.created_at < :end", staff_id: data[:staff].id, - start: Time.zone.now - 7.days) + start: summary_stats[:week_start], + end: summary_stats[:week_end]) data[:sent_comments] = total_comments - .where("task_comments.user_id = :staff_id AND task_comments.created_at > :start", + .where("task_comments.user_id = :staff_id AND task_comments.created_at >= :start AND task_comments.created_at < :end", staff_id: data[:staff].id, - start: Time.zone.now - 7.days) + start: summary_stats[:week_start], + end: summary_stats[:week_end]) data[:total_comments] = total_comments @@ -200,14 +206,11 @@ def populate_summary_stats(summary_stats, tutorial_stream, tutorial, row) row.replace(data) end - def send_weekly_status_email(summary_stats) + def create_weekly_summary_notification(summary_stats) return unless NotificationSetting.for(user).weekly_summary_for?(unit) - begin - NotificationsMailer.weekly_staff_summary(self, summary_stats).deliver_now - rescue StandardError => e - Rails.logger.error "Failed to send weekly staff summary email to #{user.email} - #{e.message}" - end + data = WeeklySummaryNotificationBuilder.for_staff(self, summary_stats) + Notification.create_weekly_summary(recipient: user, unit: unit, data: data) end def ensure_valid_user_for_role diff --git a/app/services/notification_group_builder.rb b/app/services/notification_group_builder.rb index fbb05e0c33..d38eff581c 100644 --- a/app/services/notification_group_builder.rb +++ b/app/services/notification_group_builder.rb @@ -30,6 +30,7 @@ def groups def grouping_key(notification) state_key = notification.read_at ? "read:#{notification.read_at.to_f}" : 'unread' return "#{state_key}:communication-email:#{notification.id}" if notification.kind == 'communication_email' + return "#{state_key}:weekly-summary:#{notification.id}" if notification.kind == 'weekly_summary' # A moderation note belongs to one staff member's thread, so it groups by that # rather than joining the task's other notifications. @@ -59,6 +60,7 @@ def build_group(items) .sort_by { |notification| [notification.created_at, notification.id] } detail = detail_for(items, counts, latest_status) project = feedback_warning ? nil : (latest.project || task&.project) + weekly_summary = latest.weekly_summary_data { key: grouping_key(latest), @@ -85,7 +87,8 @@ def build_group(items) tutor_note_on_task_tutor: tutor_note_on_task_tutor?(tutor_notes.first, task), overseer_assessment_id: overseer_assessment_id(items), message_subject: latest.message_subject, - message_body: latest.message_body, + message_body: weekly_summary ? nil : latest.message_body, + weekly_summary: weekly_summary, detail: detail, summary: "#{subject_for(task, latest.recipient, counts, latest)} - #{detail}" } @@ -137,6 +140,7 @@ def overseer_assessment_id(items) def subject_for(task, recipient, counts, latest) return latest.message_subject.presence || 'Email message' if counts['communication_email'].positive? + return latest.message_subject.presence || 'Weekly summary' if counts['weekly_summary'].positive? return 'Portfolio' if Notification::PORTFOLIO_KINDS.any? { |kind| counts[kind].positive? } return 'Feedback required' if counts['feedback_warning'].positive? return 'Unit notification' if task.nil? @@ -151,6 +155,7 @@ def subject_for(task, recipient, counts, latest) def detail_for(items, counts, latest_status) details = [ communication_detail(items, counts), + weekly_summary_detail(items, counts), feedback_warning_detail(counts), *event_details(counts), moderation_detail(items), @@ -163,6 +168,21 @@ def detail_for(items, counts, latest_status) details.to_sentence.upcase_first end + def weekly_summary_detail(items, counts) + return unless counts['weekly_summary'].positive? + + data = items.max_by(&:created_at).weekly_summary_data || {} + if data['audience'] == 'staff' + assessed = data.fetch('assessed_tasks', 0) + awaiting = data.fetch('awaiting_feedback', 0) + "You assessed #{pluralize(assessed, 'task')} and have #{pluralize(awaiting, 'task')} awaiting feedback" + else + received = data.fetch('received_comments', 0) + activity = data.fetch('task_activity', 0) + "You received #{pluralize(received, 'comment')} and had activity on #{pluralize(activity, 'task')} this week" + end + end + def feedback_warning_detail(counts) count = counts['feedback_warning'] return unless count.positive? diff --git a/app/services/weekly_summary_notification_builder.rb b/app/services/weekly_summary_notification_builder.rb new file mode 100644 index 0000000000..32bac4ea40 --- /dev/null +++ b/app/services/weekly_summary_notification_builder.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +class WeeklySummaryNotificationBuilder + COMMENT_TYPES = %i[text assessment audio image pdf discussion extension].freeze + + def self.for_student(project, summary_stats, did_revert_to_pass: false) + week_start = summary_stats.fetch(:week_start) + week_end = summary_stats.fetch(:week_end) + engagements = project.task_engagements.where(engagement_time: week_start...week_end) + student_statuses = [ + TaskStatus.not_started.name, + TaskStatus.need_help.name, + TaskStatus.working_on_it.name, + TaskStatus.ready_for_feedback.name + ] + + { + audience: 'student', + week_start: week_start.iso8601, + week_end: week_end.iso8601, + unit_comments: summary_stats.fetch(:unit_week_comments), + unit_task_activity: summary_stats.fetch(:unit_week_engagements), + sent_comments: project.comments.where(user_id: project.user_id, created_at: week_start...week_end).count, + received_comments: project.comments.where(recipient_id: project.user_id, created_at: week_start...week_end).count, + task_activity: engagements.count, + student_task_activity: engagements.where(engagement: student_statuses).count, + tutor_allocated: project.tutorial_enrolments.exists?, + did_revert_to_pass: did_revert_to_pass, + portfolio_exists: project.portfolio_exists?, + top_tasks: student_top_tasks(project) + } + end + + def self.for_staff(unit_role, summary_stats) + unit = summary_stats.fetch(:unit) + staff = unit_role.user + week_start = summary_stats.fetch(:week_start) + week_end = summary_stats.fetch(:week_end) + staff_stats = summary_stats.fetch(:staff).fetch(staff) + + data = { + audience: 'staff', + week_start: week_start.iso8601, + week_end: week_end.iso8601, + has_students: unit_role.has_students?, + is_convenor: unit_role.is_convenor?, + unit_comments: summary_stats.fetch(:unit_week_comments), + unit_task_activity: summary_stats.fetch(:unit_week_engagements), + received_comments: unit.comments.where( + recipient_id: staff.id, + created_at: week_start...week_end, + content_type: COMMENT_TYPES + ).count, + sent_comments: unit.comments.where( + user_id: staff.id, + created_at: week_start...week_end, + content_type: COMMENT_TYPES + ).count, + student_task_activity: staff_stats.fetch(:weekly_engagements_count), + assessed_tasks: staff_stats.fetch(:staff_engagements), + discussed_tasks: staff_stats.fetch(:weekly_total_tasks_discussed), + awaiting_feedback: staff_stats.fetch(:tasks_awaiting_feedback_count), + oldest_task_days: staff_stats.fetch(:oldest_task_days), + reverted_students: Array(summary_stats.dig(:revert, staff)).map { |project| project.student.name } + } + + if unit_role.is_convenor? + data[:reverted_student_count] = summary_stats.fetch(:revert_count) + data[:tutorial_streams] = convenor_tutorial_streams(summary_stats) + end + + data + end + + def self.student_top_tasks(project) + project.top_tasks.map do |top_task| + definition = top_task.fetch(:task_definition) + { + abbreviation: definition.abbreviation, + name: definition.name, + reason: top_task.fetch(:reason).to_s, + reason_label: { overdue: 'Overdue', soon: 'Due soon', ahead: 'Get ahead' }.fetch( + top_task.fetch(:reason), + top_task.fetch(:reason).to_s.humanize + ), + status: top_task[:status]&.to_s + } + end + end + private_class_method :student_top_tasks + + def self.convenor_tutorial_streams(summary_stats) + summary_stats.fetch(:tutorial_streams).filter_map do |stream, stream_data| + next unless stream_data[:stream_linked_to_task_definition] + + rows = Array(stream_data[:unit_roles]).filter_map do |unit_role, data| + next unless data[:number_of_students].to_i.positive? + + { + tutor_name: unit_role.user.name, + students: data[:number_of_students], + total_assessments: data[:total_staff_engagements], + weekly_assessments: data[:staff_engagements], + total_comments: data[:total_comments].uniq.count, + weekly_comments: data[:sent_comments].uniq.count, + awaiting_feedback: data[:tasks_awaiting_feedback_count], + oldest_task_days: data[:oldest_task_days], + total_discussions: data[:total_tasks_discussed], + weekly_discussions: data[:weekly_tasks_discussed] + } + end + rows.sort_by! { |row| [-row[:oldest_task_days].to_i, -row[:awaiting_feedback].to_i, row[:tutor_name]] } + + { + name: stream.name, + unallocated_students: stream_data[:num_students_without_tutors].to_i, + tutors: rows + } + end + end + private_class_method :convenor_tutorial_streams +end diff --git a/app/sidekiq/create_weekly_summary_notifications_job.rb b/app/sidekiq/create_weekly_summary_notifications_job.rb new file mode 100644 index 0000000000..e103dbc026 --- /dev/null +++ b/app/sidekiq/create_weekly_summary_notifications_job.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'set' + +class CreateWeeklySummaryNotificationsJob + include Sidekiq::Job + + PROCESSED_KEY_PREFIX = 'notifications:weekly_summary:created' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['create-weekly-summary-notifications'] }, + on_conflict: :reject, + retry: 2 + + def perform(at = nil) + create_summaries(at: at, force: false) + end + + def perform_now + create_summaries(at: nil, force: true) + end + + private + + def create_summaries(at:, force:) + week_end = at.present? ? Time.zone.parse(at) : Time.current + due_timezones = due_settings_by_timezone(week_end, force: force).reject do |timezone, _settings| + timezone_processed?(timezone, week_end) + end + recipient_ids = due_timezones.values.flatten.to_set(&:user_id) + return if recipient_ids.empty? + + summary_stats = { + week_end: week_end, + week_start: week_end - 7.days, + weeks_comments: TaskComment.where(created_at: (week_end - 7.days)...week_end).count, + weeks_engagements: TaskEngagement.where(engagement_time: (week_end - 7.days)...week_end).count + } + + project_units = Project.where(user_id: recipient_ids, enrolled: true).select(:unit_id) + role_units = UnitRole.where(user_id: recipient_ids).select(:unit_id) + units = Unit.where(id: project_units).or(Unit.where(id: role_units)) + + units.where(active: true, send_notifications: true).find_each do |unit| + next unless week_end > unit.start_date && summary_stats[:week_start] < unit.end_date + + unit.create_weekly_summary_notifications(summary_stats, recipient_ids: recipient_ids) + end + + due_timezones.each_key { |timezone| mark_timezone_processed(timezone, week_end) } + end + + def due_settings_by_timezone(at, force:) + NotificationSetting.includes(user: :notification_unit_overrides).find_each.select do |settings| + (force || settings.weekly_summary_due?(at)) && settings.weekly_summary_opted_in? + end.group_by(&:digest_timezone) + end + + def timezone_processed?(timezone, at) + Sidekiq.redis { |redis| redis.get(processed_key(timezone, at)).present? } + end + + def mark_timezone_processed(timezone, at) + Sidekiq.redis { |redis| redis.set(processed_key(timezone, at), '1', ex: 8.days.to_i) } + end + + def processed_key(timezone, at) + local_date = at.in_time_zone(timezone).to_date.iso8601 + "#{PROCESSED_KEY_PREFIX}:#{timezone}:#{local_date}" + end +end diff --git a/app/sidekiq/poll_notification_digests_job.rb b/app/sidekiq/poll_notification_digests_job.rb index cc4e458624..9d197df064 100644 --- a/app/sidekiq/poll_notification_digests_job.rb +++ b/app/sidekiq/poll_notification_digests_job.rb @@ -9,6 +9,9 @@ class PollNotificationDigestsJob retry: 1 def perform + # Create the 7am weekly summaries first so a digest due at the same time can include them. + CreateWeeklySummaryNotificationsJob.new.perform + NotificationSetting.due.find_each do |setting| SendNotificationDigestJob.perform_async(setting.id) end diff --git a/app/views/notifications_mailer/notification_digest.html.erb b/app/views/notifications_mailer/notification_digest.html.erb index d0fb1bd292..e965c3afc0 100644 --- a/app/views/notifications_mailer/notification_digest.html.erb +++ b/app/views/notifications_mailer/notification_digest.html.erb @@ -82,6 +82,103 @@ <% unless group_section[:kind] && group[:event_count] == group[:counts][group_section[:kind]] %>
<%= group[:detail] %>
<% end %> + <% if group[:weekly_summary] %> + <% summary = group[:weekly_summary] %> +
<%= weekly_summary_period(summary) %>
+ + + <% if summary['audience'] == 'staff' %> + <% [['Assessed', summary['assessed_tasks']], ['Awaiting feedback', summary['awaiting_feedback']], ['Comments sent', summary['sent_comments']], ['Tasks discussed', summary['discussed_tasks']]].each do |label, value| %> + + <% end %> + <% else %> + <% [['Comments received', summary['received_comments']], ['Comments posted', summary['sent_comments']], ['Your task activity', summary['task_activity']], ['Unit task activity', summary['unit_task_activity']]].each do |label, value| %> + + <% end %> + <% end %> + +
+
<%= value %>
+
<%= label %>
+
+
<%= value %>
+
<%= label %>
+
+ + <% if summary['audience'] == 'staff' %> +
Across the unit there were <%= pluralize(summary['unit_comments'], 'comment') %> and <%= pluralize(summary['unit_task_activity'], 'task status change') %>. Your students' tasks changed state <%= pluralize(summary['student_task_activity'], 'time') %>, and you received <%= pluralize(summary['received_comments'], 'comment') %>.
+ <% else %> +
Across the unit there were <%= pluralize(summary['unit_comments'], 'comment') %>. Of your task activity, <%= pluralize(summary['student_task_activity'], 'change') %> came from your own progress.
+ <% end %> + + <% if summary['audience'] == 'staff' && summary['awaiting_feedback'].to_i.positive? %> +
The oldest task awaiting feedback has waited <%= pluralize(summary['oldest_task_days'], 'day') %>.
+ <% elsif summary['audience'] == 'student' %> + <% unless summary['tutor_allocated'] %> +
You are not currently allocated to a tutor. Please check your tutorial allocation.
+ <% end %> + <% if summary['top_tasks'].any? %> +
What to focus on next
+
+ <% else %> +
<%= summary['portfolio_exists'] ? 'You have completed your tasks and prepared your portfolio.' : 'You have completed your tasks—make sure you log in and prepare your portfolio.' %>
+ <% end %> + <% end %> + + <% if summary['did_revert_to_pass'] %> +
Your target grade was reset to Pass so you can focus on catching up with your Pass tasks.
+ <% end %> + + <% if summary['reverted_students'].present? %> +
The following students were advised to focus on their Pass tasks: <%= summary['reverted_students'].to_sentence %>.
+ <% end %> + + <% if summary['is_convenor'] && summary['tutorial_streams'].present? %> +
Tutor progress
+ <% summary['tutorial_streams'].each do |stream| %> +
<%= stream['name'] %>
+ <% if stream['unallocated_students'].to_i.positive? %> +
<%= pluralize(stream['unallocated_students'], 'student') %> not allocated to a tutorial in this stream.
+ <% end %> + <% if stream['tutors'].any? %> +
+ + + + + + + + + + + + + <% stream['tutors'].each do |tutor| %> + + + + + + + + + <% end %> + +
TutorStudentsAssessments
week / total
Comments
week / total
Awaiting
count / oldest
Discussed
week / total
<%= tutor['tutor_name'] %><%= tutor['students'] %><%= tutor['weekly_assessments'] %> / <%= tutor['total_assessments'] %><%= tutor['weekly_comments'] %> / <%= tutor['total_comments'] %><%= tutor['awaiting_feedback'] %> / <%= tutor['oldest_task_days'] %>d<%= tutor['weekly_discussions'] %> / <%= tutor['total_discussions'] %>
+
+ <% end %> + <% end %> + <% end %> + <% end %> <% unless group_section[:kind] %>
<%= notification_timestamp(group) %>
<% end %> @@ -97,6 +194,12 @@ Open inbox
+ <% elsif group[:weekly_summary] && group[:project_id] %> + <% elsif group[:project_id] %>
diff --git a/app/views/notifications_mailer/notification_digest.text.erb b/app/views/notifications_mailer/notification_digest.text.erb index bc67424c5b..c5ef35bd8c 100644 --- a/app/views/notifications_mailer/notification_digest.text.erb +++ b/app/views/notifications_mailer/notification_digest.text.erb @@ -18,10 +18,61 @@ You have <%= @notification_count %> new <%= 'notification'.pluralize(@notificati <% unless group_section[:kind] && group[:event_count] == group[:counts][group_section[:kind]] %> <%= group[:detail] %> <% end %> +<% if group[:weekly_summary] %> +<% summary = group[:weekly_summary] %> + <%= weekly_summary_period(summary) %> +<% if summary['audience'] == 'staff' %> + Assessed: <%= summary['assessed_tasks'] %> + Awaiting feedback: <%= summary['awaiting_feedback'] %><% if summary['awaiting_feedback'].to_i.positive? %> (oldest: <%= pluralize(summary['oldest_task_days'], 'day') %>)<% end %> + Comments sent: <%= summary['sent_comments'] %> + Comments received: <%= summary['received_comments'] %> + Tasks discussed: <%= summary['discussed_tasks'] %> + Across the unit: <%= pluralize(summary['unit_comments'], 'comment') %> and <%= pluralize(summary['unit_task_activity'], 'task status change') %>. + Your students' tasks changed state <%= pluralize(summary['student_task_activity'], 'time') %> and you received <%= pluralize(summary['received_comments'], 'comment') %>. +<% else %> + Comments received: <%= summary['received_comments'] %> + Comments posted: <%= summary['sent_comments'] %> + Your task activity: <%= summary['task_activity'] %> + Unit task activity: <%= summary['unit_task_activity'] %> + Your own progress accounted for <%= pluralize(summary['student_task_activity'], 'change') %>; across the unit there were <%= pluralize(summary['unit_comments'], 'comment') %>. +<% unless summary['tutor_allocated'] %> + Warning: You are not currently allocated to a tutor. Please check your tutorial allocation. +<% end %> +<% if summary['top_tasks'].any? %> + What to focus on next: +<% summary['top_tasks'].each do |task| %> + - <%= task['abbreviation'] %> — <%= task['name'] %> (<%= task['reason_label'] %>) + <%= @doubtfire_host %>/projects/<%= group[:project_id] %>/dashboard/<%= task['abbreviation'] %> +<% end %> +<% else %> + <%= summary['portfolio_exists'] ? 'You have completed your tasks and prepared your portfolio.' : 'You have completed your tasks—make sure you log in and prepare your portfolio.' %> +<% end %> +<% if summary['did_revert_to_pass'] %> + Your target grade was reset to Pass so you can focus on catching up with your Pass tasks. +<% end %> +<% end %> +<% if summary['reverted_students'].present? %> + Students advised to focus on their Pass tasks: <%= summary['reverted_students'].to_sentence %>. +<% end %> +<% if summary['is_convenor'] && summary['tutorial_streams'].present? %> + Tutor progress: +<% summary['tutorial_streams'].each do |stream| %> + <%= stream['name'] %> +<% if stream['unallocated_students'].to_i.positive? %> + Warning: <%= pluralize(stream['unallocated_students'], 'student') %> not allocated to a tutorial in this stream. +<% end %> +<% stream['tutors'].each do |tutor| %> + - <%= tutor['tutor_name'] %>: <%= tutor['students'] %> students; assessments <%= tutor['weekly_assessments'] %> this week / <%= tutor['total_assessments'] %> total; comments <%= tutor['weekly_comments'] %> / <%= tutor['total_comments'] %>; awaiting <%= tutor['awaiting_feedback'] %> (oldest <%= tutor['oldest_task_days'] %>d); discussed <%= tutor['weekly_discussions'] %> / <%= tutor['total_discussions'] %> +<% end %> +<% end %> +<% end %> +<% end %> <% if group[:task] %> <%= @doubtfire_host %>/projects/<%= group[:task][:project_id] %>/dashboard/<%= group[:task][:abbreviation] %><%= '?tutor=true' if group[:task][:staff_view] %> <% elsif group.dig(:destination, :type) == 'unit_inbox' %> <%= @doubtfire_host %>/units/<%= group.dig(:destination, :unit_id) %>/tasks/inbox +<% elsif group[:weekly_summary] && group[:project_id] %> + <%= @doubtfire_host %>/projects/<%= group[:project_id] %>/dashboard <% elsif group[:project_id] %> <%= @doubtfire_host %>/projects/<%= group[:project_id] %>/portfolio <% end %> diff --git a/app/views/notifications_mailer/weekly_staff_summary.html.erb b/app/views/notifications_mailer/weekly_staff_summary.html.erb deleted file mode 100644 index c8360dfde8..0000000000 --- a/app/views/notifications_mailer/weekly_staff_summary.html.erb +++ /dev/null @@ -1,160 +0,0 @@ - - - - - - -
-

<%= @summary_stats[:unit].name %> - Weekly Summary

-

<%= "#{@summary_stats[:week_start].day.ordinalize} #{@summary_stats[:week_start].strftime("%B %Y")}" %> to <%= "#{@summary_stats[:week_end].day.ordinalize} #{@summary_stats[:week_end].strftime("%B %Y")}" %>

- -

Hi <%= @staff.first_name %>,

-

- Hope you had a good week! Here's a summary of what has happened in this unit over the last week. -

-<% if @unit_role.has_students? %> -

-

    -
  • In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made this unit.
  • -
  • You received <%= @data[:received_comments] %> comment<%= "s" if @data[:received_comments] != 1 %> and posted a total of <%= @data[:sent_comments] %> comment<%= "s" if @data[:sent_comments] != 1 %>.
  • -
  • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
  • -
  • Your student's tasks changed state <%= @data[:weekly_engagements_count] %> time<%= "s" unless @data[:weekly_engagements_count] == 1 %>.
  • -
  • You assigned a new status to <%= @data[:staff_engagements] %> task<%= "s" unless @data[:staff_engagements] == 1 %>.
  • -
  • You have discussed <%= @data[:weekly_total_tasks_discussed] %> task<%= "s" unless @data[:weekly_total_tasks_discussed] == 1 %>.
  • -
  • You have <%= @data[:tasks_awaiting_feedback_count] %> task<%= "s" unless @data[:tasks_awaiting_feedback_count] == 1 %> waiting for feedback.
  • -<% if @data[:tasks_awaiting_feedback_count] > 0 %> -
  • The oldest of these tasks was submitted <%= @data[:oldest_task_days] %> day<%= "s" unless @data[:oldest_task_days] == 1 %> ago.
  • -<% end %> -
-

-<% if @summary_stats[:revert][@staff].count > 0 %> -

- The following students in your tutorials have been advised to revert their grade to Pass as they are falling behind with their pass tasks: -

    -<% @summary_stats[:revert][@staff].each do |p|%> -
  • <%= p.student.name %>
  • -<% end %> -
-

-<% end %> -<%# Else if unit role has no students %> -<% else %> -

-

    -
  • In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit.
  • -
  • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
  • -
-

-<% end %> - -<% if @unit_role.is_convenor? && !@summary_stats[:tutorial_streams].nil? && !@summary_stats[:tutorial_streams].empty?%> - <%# Create a table for each tutorial stream %> - <% @summary_stats[:tutorial_streams].each do |tutorial_stream, tutorial_stream_data | %> - <% next unless tutorial_stream_data[:stream_linked_to_task_definition] %> -

- Tutorial Stream: <%= tutorial_stream.name %> -

- <%# Skip table if no tutorials%> - <% unless tutorial_stream_data.nil? || tutorial_stream_data[:tutorials].nil?%> - - - - - - - - - - - - - - - - - - - - - - <% sorted_data = tutorial_stream_data[:unit_roles].sort_by do |unit_role, data| - [-data[:oldest_task_days].to_i, -data[:tasks_awaiting_feedback_count].to_i] - end %> - <% sorted_data.each do | unit_role, data | %> - <% next unless data[:number_of_students] > 0%> - - - - - - - - - - - - - <% end %> - -
NameStudentsAssessmentsCommentsAwaiting FeedbackOldest TaskDiscussions
TotalWeekTotalWeekTotalWeek
<%= '%-12s' % unit_role.user.name.truncate(14) %><%= '%8i' % data[:number_of_students] %><%= '%7i' % data[:total_staff_engagements] %><%= '%6i' % data[:staff_engagements] %><%= '%5i' % data[:total_comments].uniq.count %><%= '%5i' % data[:sent_comments].uniq.count %><%= '%8i' % data[:tasks_awaiting_feedback_count] %>><%= '%6i' % data[:oldest_task_days] %> days<%= '%5i' % data[:total_tasks_discussed] %><%= '%5i' % data[:weekly_tasks_discussed] %>
- <% end %> - <% if tutorial_stream_data[:num_students_without_tutors] > 0 %> -

- Please note that <%=tutorial_stream_data[:num_students_without_tutors]%> student<%= "s" unless tutorial_stream_data[:num_students_without_tutors] == 1 %> <%= are_is(tutorial_stream_data[:num_students_without_tutors]) %> not allocated to a tutorial in this stream. Work submitted by <%= this_these(tutorial_stream_data[:num_students_without_tutors]) %> student<%= "s" unless tutorial_stream_data[:num_students_without_tutors] == 1 %> will not appear in any of the tutor inboxes! -

- <% end %> - <% end %> -<% end %> -

- Cheers,
- The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %> -

-
-
- - diff --git a/app/views/notifications_mailer/weekly_staff_summary.text.erb b/app/views/notifications_mailer/weekly_staff_summary.text.erb deleted file mode 100644 index 1bdde187a8..0000000000 --- a/app/views/notifications_mailer/weekly_staff_summary.text.erb +++ /dev/null @@ -1,70 +0,0 @@ -Hi <%= @staff.first_name %>, - -<%= @summary_stats[:unit].name %> - Weekly Summary -<%= "#{@summary_stats[:week_start].day.ordinalize} #{@summary_stats[:week_start].strftime("%B %Y")}" %> to <%= "#{@summary_stats[:week_end].day.ordinalize} #{@summary_stats[:week_end].strftime("%B %Y")}" %> - -Hope you had a good week! Here's a summary of what has happened in this unit over the last week. - -<% if @unit_role.has_students? %> -* In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made this unit. -* You received <%= @data[:received_comments] %> comment<%= "s" if @data[:received_comments] != 1 %> and posted a total of <%= @data[:sent_comments] %> comment<%= "s" if @data[:sent_comments] != 1 %>. -* Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit. -* Your student's tasks changed state <%= @data[:weekly_engagements_count] %> time<%= "s" unless @data[:weekly_engagements_count] == 1 %>. -* You assigned a new status to <%= @data[:staff_engagements] %> task<%= "s" unless @data[:staff_engagements] == 1 %>. -* You have discussed <%= @data[:weekly_total_tasks_discussed] %> task<%= "s" unless @data[:weekly_total_tasks_discussed] == 1 %>. -* You have <%= @data[:tasks_awaiting_feedback_count] %> task<%= "s" unless @data[:tasks_awaiting_feedback_count] == 1 %> waiting for feedback. -<% if @data[:tasks_awaiting_feedback_count] > 0 %> -* The oldest of these tasks was submitted <%= @data[:oldest_task_days] %> day<%= "s" unless @data[:oldest_task_days] == 1 %> ago. -<% end %> -<% if @summary_stats[:revert][@staff].count > 0 %> - -The following students in your tutorials have been advised to revert their grade to Pass as they are falling behind with their pass tasks: -<% @summary_stats[:revert][@staff].each do |p|%> -* <%= p.student.name %> -<% end %> -<% end %> -<% else %> -* In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit. -* Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit. -<% end %> - -<% if @unit_role.is_convenor? && !@summary_stats[:tutorial_streams].nil? && !@summary_stats[:tutorial_streams].empty?%> -Here is a quick summary of how the tutors are going in this unit: - -<% @summary_stats[:tutorial_streams].each do |tutorial_stream, tutorial_stream_data | %> -<% next unless tutorial_stream_data[:stream_linked_to_task_definition] %> -Tutorial Stream: <%= tutorial_stream.name %> -<% unless tutorial_stream_data.nil? || tutorial_stream_data[:tutorials].nil?%> -------------------------------------------------------------------------------------------------- -Name | Number | Task Assessments | Comments Made | Awaiting | Oldest | Tasks Discussed - | Students | Total | Week | Total | Week | Feedback | Task | Total | Week -------------------------------------------------------------------------------------------------- -<% sorted_data = tutorial_stream_data[:unit_roles].sort_by do |unit_role, data| - [-data[:oldest_task_days].to_i, -data[:tasks_awaiting_feedback_count].to_i] - end %> -<% sorted_data.each do | unit_role, data | %> -<% next unless data[:number_of_students] > 0%> -<%='%-14s' % unit_role.user.name.truncate(14) %> | <%= '%8i' % data[:number_of_students] %> | <%= '%7i' % data[:total_staff_engagements] %> | <%= '%6i' % data[:staff_engagements] %> | <%= '%5i' % data[:total_comments].uniq.count %> | <%= '%5i' % data[:sent_comments].uniq.count %> | <%= '%8i' % data[:tasks_awaiting_feedback_count] %> | <%= '%5i' % data[:oldest_task_days] %>d | <%= '%6i' % data[:total_tasks_discussed] %> | <%= '%5i' % data[:weekly_tasks_discussed] %> | -<% end %> ------------------------------------------------------------------------------------------------------------------ -<% end %> -<% if tutorial_stream_data[:num_students_without_tutors].to_i > 0 %> -Please note that <%=tutorial_stream_data[:num_students_without_tutors].to_i%> student<%= "s" unless tutorial_stream_data[:num_students_without_tutors].to_i == 1 %> <%= are_is(tutorial_stream_data[:num_students_without_tutors].to_i) %> not allocated to a tutorial in this stream. Work submitted by <%= this_these(tutorial_stream_data[:num_students_without_tutors].to_i) %> student<%= "s" unless tutorial_stream_data[:num_students_without_tutors].to_i == 1 %> will not appear in any of the tutor inboxes! -<% end %> - -<% end %> - - -<% if @summary_stats[:revert_count] > 0 %> -<%= @summary_stats[:revert_count] %> student<%= "s" unless @summary_stats[:revert_count] == 1%> <%= were_was(@summary_stats[:revert_count])%> automatically reverted to Pass target grade due to progress on their pass tasks. -<% end %> -<% end %> - -Cheers, -The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %> - ---- - -Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. - -Generated with <%= @doubtfire_product_name %> diff --git a/app/views/notifications_mailer/weekly_student_summary.html.erb b/app/views/notifications_mailer/weekly_student_summary.html.erb deleted file mode 100644 index 3a0593d36e..0000000000 --- a/app/views/notifications_mailer/weekly_student_summary.html.erb +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - -
-

<%= @summary_stats[:unit].name %> - Weekly Summary

-

<%= "#{@summary_stats[:week_start].day.ordinalize} #{@summary_stats[:week_start].strftime("%B %Y")}" %> to <%= "#{@summary_stats[:week_end].day.ordinalize} #{@summary_stats[:week_end].strftime("%B %Y")}" %>

- -

Hi <%= @student.first_name %>,

-<% if @did_revert_to_pass %> -

Hope you had a good week!

-

Before we get into the summary, it seems that you are falling behind in your Pass Tasks. It is really important that you catch up with these tasks, as you must have all Pass tasks marked as Complete to Pass the unit. I've reset your Target Grade to Pass for the moment, to help you focus on these tasks. I would like to encourage you to work through the Pass tasks in order to catch up as quickly as you can. Once you have caught up, you can upgrade your Target Grade again, and go back and complete any higher grade tasks you skipped.

-

With that out of the way, here's a summary of what has happened in this unit over the last week and some notes on what you should do next.

-<% else %> -

Hope you had a good week! Here's a summary of what has happened in this unit over the last week and some notes on what you should do next.

-<% end %> - -<% if @project.tutorial_enrolments.blank? %> -

Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that on the tutorials page.

-<% end %> -

Here is what you should focus on right now.

- -<% if @top_tasks && @top_tasks.count > 0 %> -<% if @overdue_top && @overdue_top.count > 0 %> -

Catch up by completing the following overdue task<%="s" if @overdue_top.count > 1%>!

-
    -<% @overdue_top.each do |ot| %> -
  • <%= top_task_desc(ot) %>
  • -<% end %> -
-<% end %> -<% if @soon_top && @soon_top.count > 0 %> -

Work to get the following task<%="s" if @soon_top.count > 1%> done, as these are due soon!

-
    -<% @soon_top.each do |st| %> -
  • <%= top_task_desc(st) %>
  • -<% end %> -
-<% end %> -<% if @ahead_top && @ahead_top.count > 0 %> -

Get ahead by working on the following task<%="s" if @ahead_top.count > 1%> next!

-
    -<% @ahead_top.each do |at| %> -
  • <%= top_task_desc(at) %>
  • -<% end %> -
-<% end %> -<% elsif @project.portfolio_exists? %> -

Its time to Party! You have completed all of the tasks and prepared your portfolio.

-<% else %> -

Its almost party time... You have completed all of the tasks, now make sure you login and prepare your portfolio.

-<% end %> -

- What has happened in <%= @doubtfire_product_name %> this week: -

    -
  • In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit.
  • -
  • You posted a total of <%= @sent_comments %> comment<%= "s" if @sent_comments != 1 %>, and received back <%= @received_comments %> comment<%= "s" if @received_comments != 1 %> <%= "- try posting some comments this week" if @sent_comments == 0 %>
  • -
  • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
  • -
  • Your tasks changed state <%= @engagements_count %> time<%= "s" unless @engagements_count == 1 %> <%= "- looks like you need to be more active" if @student_engagements == 0%>
  • -
- We hope your studies are going well, and look forward to your submissions over the next week! -

- -

- Cheers,
- The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> -

-
- - - diff --git a/app/views/notifications_mailer/weekly_student_summary.text.erb b/app/views/notifications_mailer/weekly_student_summary.text.erb deleted file mode 100644 index 808fc7c3bd..0000000000 --- a/app/views/notifications_mailer/weekly_student_summary.text.erb +++ /dev/null @@ -1,67 +0,0 @@ -Hi <%= @student.first_name %>, - -<%= @summary_stats[:unit].name %> - Weekly Summary -<%= "#{@summary_stats[:week_start].day.ordinalize} #{@summary_stats[:week_start].strftime("%B %Y")}" %> to <%= "#{@summary_stats[:week_end].day.ordinalize} #{@summary_stats[:week_end].strftime("%B %Y")}" %> - -<% if @did_revert_to_pass %> -Hope you had a good week! - -Before we get into the summary, it seems that you are falling behind in your Pass Tasks. It is really important that you catch up with these tasks, as you must have all Pass tasks marked as Complete to Pass the unit. I've reset your Target Grade to Pass for the moment, to help you focus on these tasks. I would like to encourage you to work through the Pass tasks in order to catch up as quickly as you can. Once you have caught up, you can upgrade your Target Grade again, and go back and complete any higher grade tasks you skipped. - -With that out of the way, here's a summary of what has happened in this unit over the last week and some notes on what you should do next. -<% else %> -Hope you had a good week! Here's a summary of what has happened in this unit over the last week and some notes on what you should do next. -<% end %> - -<% if @project.tutorial_enrolments.blank? %> - -Firstly... it looks like you are not assigned a tutor! Please login and make sure your tutorial is correctly set. You should be able to do that here: <%= @doubtfire_host %>/#/projects/<%= @project.id %>/tutorials - -Here is what you should focus on right now. -<% else %> - -Firstly, here is what you should focus on right now. -<% end %> - -<% if @top_tasks && @top_tasks.count > 0 %> -<% if @overdue_top && @overdue_top.count > 0 %> -Catch up by completing the following *overdue* task<%="s" if @overdue_top.count > 1%>! -<% @overdue_top.each do |ot| %> -* <%= top_task_desc(ot) %> -<% end %> -<% end %> -<% if @soon_top && @soon_top.count > 0 %> -Work to get the following task<%="s" if @soon_top.count > 1%> done, as these are *due* soon! -<% @soon_top.each do |st| %> -* <%= top_task_desc(st) %> -<% end %> -<% end %> -<% if @ahead_top && @ahead_top.count > 0 %> -Get ahead by working on the following task<%="s" if @ahead_top.count > 1%> next! -<% @ahead_top.each do |at| %> -* <%= top_task_desc(at) %> -<% end %> -<% end %> -<% elsif @project.portfolio_exists? %> -Its time to Party! You have completed all of the tasks and prepared your portfolio. -<% else %> -Its almost party time... You have completed all of the tasks, now make sure you login and prepare your portfolio. -<% end %> - -What has happened in <%= @doubtfire_product_name %> this week: - -* In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit. -* You posted a total of <%= @sent_comments %> comment<%= "s" if @sent_comments != 1 %>, and received back <%= @received_comments %> comment<%= "s" if @received_comments != 1 %> <%= "- try posting some comments this week" if @sent_comments == 0 %> -* Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit. -* Your tasks changed state <%= @engagements_count %> time<%= "s" unless @engagements_count == 1 %> <%= "- looks like you need to be more active" if @student_engagements == 0%> - -We hope your studies are going well, and look forward to your submissions over the next week! - -Cheers, -The <%= @doubtfire_product_name %> Team on behalf of <%= @tutor.name %> - ---- - -Visit <%= @unsubscribe_url%> to unsubscribe from these notifications. - -Generated with <%= @doubtfire_product_name %> diff --git a/lib/tasks/send_status_emails.rake b/lib/tasks/send_status_emails.rake index 98ce225b57..1127bd8ba3 100644 --- a/lib/tasks/send_status_emails.rake +++ b/lib/tasks/send_status_emails.rake @@ -1,16 +1,6 @@ namespace :mailer do + desc 'Create this week\'s opted-in summary notifications without sending legacy emails' task send_status_emails: :environment do - summary_stats = {} - - summary_stats[:week_end] = Time.zone.now - summary_stats[:week_start] = summary_stats[:week_end] - 7.days - summary_stats[:weeks_comments] = TaskComment.where("created_at >= :start AND created_at < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count - summary_stats[:weeks_engagements] = TaskEngagement.where("engagement_time >= :start AND engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count - - Unit.where(active: true).find_each do |unit| - next unless summary_stats[:week_end] > unit.start_date && summary_stats[:week_start] < unit.end_date - - unit.send_weekly_status_emails(summary_stats) - end + CreateWeeklySummaryNotificationsJob.new.perform_now end end diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index df7be489bd..c44a3b8785 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -30,6 +30,7 @@ def test_get_returns_only_current_users_grouped_notifications assert_equal 1, last_response_body['groups'].count assert_equal @task.id, last_response_body.dig('groups', 0, 'task', 'id') assert_equal 1, last_response_body['unread_count'] + assert_equal({ @unit.id.to_s => 1 }, last_response_body['unread_counts_by_unit']) end def test_unread_count_counts_a_task_group_instead_of_each_event @@ -40,6 +41,18 @@ def test_unread_count_counts_a_task_group_instead_of_each_event assert_equal 200, last_response.status assert_equal 1, last_response_body['count'] + assert_equal({ @unit.id.to_s => 1 }, last_response_body['unread_counts_by_unit']) + end + + def test_unread_count_returns_grouped_counts_for_each_unit + other_unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + FactoryBot.create(:notification, recipient: @student, unit: other_unit) + + get '/api/notifications/unread_count' + + assert_equal 200, last_response.status + assert_equal 2, last_response_body['count'] + assert_equal({ @unit.id.to_s => 1, other_unit.id.to_s => 1 }, last_response_body['unread_counts_by_unit']) end def test_feedback_warnings_group_by_unit_and_email_delivery_batch @@ -105,6 +118,34 @@ def test_communication_emails_are_returned_individually_with_their_full_content assert_equal ['Full email body 0', 'Full email body 1'], last_response_body['groups'].pluck('message_body').sort end + def test_weekly_summaries_are_returned_individually_with_structured_statistics + @student.received_notifications.destroy_all + settings = NotificationSetting.for(@student) + settings.update!(channels: settings.channels.merge('weekly_summary' => %w[in_app email])) + data = { + audience: 'student', + week_start: 1.week.ago.iso8601, + week_end: Time.current.iso8601, + unit_comments: 8, + unit_task_activity: 12, + sent_comments: 2, + received_comments: 3, + task_activity: 4, + student_task_activity: 4, + tutor_allocated: true, + top_tasks: [] + } + Notification.create_weekly_summary(recipient: @student, unit: @unit, project: @project, data: data) + + get '/api/notifications', state: 'unread' + + assert_equal 200, last_response.status + assert_equal 1, last_response_body['unread_count'] + assert_equal({ 'weekly_summary' => 1 }, last_response_body.dig('groups', 0, 'counts')) + assert_equal 3, last_response_body.dig('groups', 0, 'weekly_summary', 'received_comments') + assert_nil last_response_body.dig('groups', 0, 'message_body') + end + def test_get_filters_groups_by_category_and_search get '/api/notifications', state: 'unread', @@ -207,6 +248,7 @@ def test_settings_start_from_the_defaults assert_equal @project.campus.timezone, last_response_body['digest_timezone'] assert_equal %w[in_app email], last_response_body.dig('channels', 'new_task_comment') assert_equal %w[in_app email], last_response_body.dig('channels', 'feedback_warning') + assert_empty last_response_body.dig('channels', 'weekly_summary') assert_empty last_response_body['units'] end diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 161e678d8e..0b55ac3b59 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -2,7 +2,7 @@ require 'grade_helper' class UnitMailTest < ActionMailer::TestCase - def test_send_summary_email + def test_create_weekly_summary_notifications_without_sending_the_legacy_emails unit = FactoryBot.create :unit summary_stats = {} @@ -11,10 +11,24 @@ def test_send_summary_email summary_stats[:week_start] = summary_stats[:week_end] - 7.days summary_stats[:weeks_comments] = TaskComment.where("created_at >= :start AND created_at < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count summary_stats[:weeks_engagements] = TaskEngagement.where("engagement_time >= :start AND engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count + (unit.active_projects.map(&:student) + unit.staff.map(&:user)).uniq.each do |recipient| + settings = NotificationSetting.for(recipient) + settings.update!(channels: settings.channels.merge('weekly_summary' => %w[in_app email])) + end + + assert_no_emails { unit.create_weekly_summary_notifications(summary_stats) } - unit.send_weekly_status_emails(summary_stats) + summaries = Notification.where(unit: unit, kind: 'weekly_summary') + assert_equal unit.active_projects.count + unit.staff.count, summaries.count + student_summary_count = summaries.filter_map(&:weekly_summary_data).count { |data| data['audience'] == 'student' } + staff_summary_count = summaries.filter_map(&:weekly_summary_data).count { |data| data['audience'] == 'staff' } + assert_equal unit.active_projects.count, student_summary_count + assert_equal unit.staff.count, staff_summary_count - assert_equal unit.active_projects.count + unit.staff.count, ActionMailer::Base.deliveries.count + # Retrying the same week's work does not create another copy. + assert_no_difference -> { summaries.reload.count } do + unit.create_weekly_summary_notifications(summary_stats) + end unit.destroy! end @@ -30,8 +44,8 @@ def test_send_portfolio_ready_from_main_convenor mail = PortfolioEvidenceMailer.portfolio_ready(project) - assert_equal 1, mail.from().count - assert_equal convenor.email, mail.from().first + assert_equal 1, mail.from.count + assert_equal convenor.email, mail.from.first assert mail.html_part.body.include? "projects/#{project.id}/portfolio" unit.destroy! end @@ -48,8 +62,8 @@ def test_send_portfolio_fail_from_main_convenor mail = PortfolioEvidenceMailer.portfolio_failed(project) - assert_equal 1, mail.from().count - assert_equal convenor.email, mail.from().first + assert_equal 1, mail.from.count + assert_equal convenor.email, mail.from.first assert mail.html_part.body.include? "projects/#{project.id}/portfolio" unit.destroy! end diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index 554cdb4ac3..ec8e4f5f41 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -19,6 +19,7 @@ def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push assert_equal NotificationSetting.default_digest_timezone, settings.digest_timezone assert_equal 1, settings.digest_weekday assert_equal %w[in_app email], settings.channels['new_task_comment'] + assert_empty settings.channels['weekly_summary'] assert_equal ['in_app'], settings.channels['communication_email'] assert_equal Notification::KINDS.sort, settings.channels.keys.sort end @@ -56,6 +57,7 @@ def test_a_muted_unit_delivers_nothing def test_weekly_summary_honours_the_unit_mute settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + settings.update!(channels: settings.channels.merge('weekly_summary' => %w[in_app email])) assert settings.weekly_summary_for?(unit) @@ -63,6 +65,28 @@ def test_weekly_summary_honours_the_unit_mute assert_not settings.weekly_summary_for?(unit) end + def test_weekly_summary_can_be_opted_into_without_changing_other_notifications + settings = NotificationSetting.for(FactoryBot.create(:user)) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + + assert_not settings.weekly_summary_for?(unit) + settings.update!(channels: settings.channels.merge('weekly_summary' => %w[in_app email])) + + assert settings.weekly_summary_for?(unit) + assert settings.delivers?(unit, 'new_task_comment', :email) + end + + def test_weekly_summary_is_due_in_the_users_timezone + user = FactoryBot.create(:user) + campus = FactoryBot.create(:campus, timezone: 'Pacific/Auckland') + FactoryBot.create(:project, user: user, campus: campus) + settings = FactoryBot.create(:notification_setting, user: user) + + assert settings.weekly_summary_due?(Time.utc(2026, 7, 26, 19, 5)) + assert_not settings.weekly_summary_due?(Time.utc(2026, 7, 26, 18, 55)) + assert_not settings.weekly_summary_due?(Time.utc(2026, 7, 26, 19, 15)) + end + def test_a_muted_unit_keeps_following_the_defaults_underneath settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index d8d2117960..6b40ac0a51 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -55,6 +55,99 @@ def test_digest_covers_every_unit_in_one_email assert_includes digest_html, second.code end + def test_digest_renders_a_convenors_weekly_tutor_progress + settings = create_settings + settings.update!(channels: settings.channels.merge('weekly_summary' => %w[in_app email])) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + data = { + audience: 'staff', + week_start: 1.week.ago.iso8601, + week_end: Time.current.iso8601, + unit_comments: 10, + unit_task_activity: 14, + sent_comments: 6, + received_comments: 2, + student_task_activity: 9, + has_students: true, + is_convenor: true, + assessed_tasks: 4, + discussed_tasks: 3, + awaiting_feedback: 2, + oldest_task_days: 5, + reverted_students: [], + reverted_student_count: 0, + tutorial_streams: [{ + name: 'On campus', + unallocated_students: 1, + tutors: [{ + tutor_name: 'Taylor Tutor', + students: 18, + total_assessments: 40, + weekly_assessments: 7, + total_comments: 62, + weekly_comments: 8, + awaiting_feedback: 3, + oldest_task_days: 4, + total_discussions: 12, + weekly_discussions: 2 + }] + }] + } + notification = Notification.create_weekly_summary(recipient: settings.user, unit: unit, data: data) + + assert_emails 1 do + SendNotificationDigestJob.new.perform(settings.id) + end + + mail = ActionMailer::Base.deliveries.last + assert_includes mail.html_part.body.to_s, 'Tutor progress' + assert_includes mail.html_part.body.to_s, 'Taylor Tutor' + assert_includes mail.html_part.body.to_s, 'week / total' + assert_match(%r{7\s*/\s*40}, mail.html_part.body.to_s) + assert_includes mail.html_part.body.to_s, '1 student not allocated' + assert_includes mail.text_part.body.to_s, 'assessments 7 this week / 40 total' + assert_not_nil notification.reload.email_sent_at + end + + def test_digest_renders_a_students_weekly_progress_and_focus_tasks + settings = create_settings + settings.update!(channels: settings.channels.merge('weekly_summary' => %w[in_app email])) + unit = FactoryBot.create(:unit, with_students: false, task_count: 0) + data = { + audience: 'student', + week_start: 1.week.ago.iso8601, + week_end: Time.current.iso8601, + unit_comments: 10, + unit_task_activity: 14, + sent_comments: 2, + received_comments: 3, + task_activity: 5, + student_task_activity: 4, + tutor_allocated: true, + did_revert_to_pass: false, + portfolio_exists: false, + top_tasks: [{ + abbreviation: 'P4', + name: 'Loops', + reason: 'soon', + reason_label: 'Due soon', + status: 'working_on_it' + }] + } + Notification.create_weekly_summary(recipient: settings.user, unit: unit, data: data) + + assert_emails 1 do + SendNotificationDigestJob.new.perform(settings.id) + end + + mail = ActionMailer::Base.deliveries.last + assert_includes mail.html_part.body.to_s, 'What to focus on next' + assert_includes mail.html_part.body.to_s, 'Due soon' + assert_includes mail.html_part.body.to_s, '/projects/' + assert_includes mail.html_part.body.to_s, '/dashboard/P4' + assert_includes mail.text_part.body.to_s, 'Comments received: 3' + end + def test_read_notification_is_not_sent_in_digest settings = create_settings notification = FactoryBot.create( diff --git a/test/sidekiq/weekly_summary_notification_job_test.rb b/test/sidekiq/weekly_summary_notification_job_test.rb new file mode 100644 index 0000000000..76fc01d00e --- /dev/null +++ b/test/sidekiq/weekly_summary_notification_job_test.rb @@ -0,0 +1,52 @@ +require 'test_helper' + +class WeeklySummaryNotificationJobTest < ActiveSupport::TestCase + class FakeRedis + def initialize + @store = {} + end + + def get(key) + @store[key] + end + + def set(key, value, **_options) + @store[key] = value + 'OK' + end + end + + def test_creates_an_opted_in_summary_at_seven_in_the_recipients_timezone_once + monday_at_seven = Time.utc(2026, 7, 26, 21, 5) # Monday 07:05 in Melbourne. + unit = FactoryBot.create(:unit) + unit.update!(start_date: monday_at_seven - 1.month, end_date: monday_at_seven + 1.month) + project = unit.active_projects.first + project.campus.update!(timezone: 'Australia/Melbourne') + settings = NotificationSetting.for(project.student) + settings.update!(channels: settings.channels.merge('weekly_summary' => ['in_app'])) + redis = FakeRedis.new + + with_sidekiq_redis(redis) do + CreateWeeklySummaryNotificationsJob.new.perform((monday_at_seven - 10.minutes).iso8601) + assert_not Notification.exists?(recipient: project.student, unit: unit, kind: 'weekly_summary') + + CreateWeeklySummaryNotificationsJob.new.perform(monday_at_seven.iso8601) + CreateWeeklySummaryNotificationsJob.new.perform((monday_at_seven + 5.minutes).iso8601) + end + + summaries = Notification.where(recipient: project.student, unit: unit, kind: 'weekly_summary') + assert_equal 1, summaries.count + assert_equal 'student', summaries.first.weekly_summary_data['audience'] + assert_not_nil summaries.first.email_processed_at + end + + private + + def with_sidekiq_redis(redis) + original = Sidekiq.method(:redis) + Sidekiq.define_singleton_method(:redis) { |&redis_block| redis_block.call(redis) } + yield + ensure + Sidekiq.define_singleton_method(:redis, original) + end +end From 7c74453a34ca85b86d18d4f989abd37b9e538efe Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:02:02 +1000 Subject: [PATCH 49/57] feat: ensure urgent emails are not sent if emails disabled --- app/mailers/portfolio_evidence_mailer.rb | 8 ++++++++ test/mailers/unit_mail_test.rb | 18 ++++++++++++++++++ test/sidekiq/notification_jobs_test.rb | 21 +++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/app/mailers/portfolio_evidence_mailer.rb b/app/mailers/portfolio_evidence_mailer.rb index 870f17bccc..3127bad53e 100644 --- a/app/mailers/portfolio_evidence_mailer.rb +++ b/app/mailers/portfolio_evidence_mailer.rb @@ -7,6 +7,7 @@ def add_general def task_pdf_failed(project, tasks) return nil if project.nil? || tasks.nil? || tasks.empty? + return nil unless notification_email_enabled?(project, 'pdf_generation_failed') add_general @student = project.student @@ -39,6 +40,7 @@ def task_pdf_ready_message(project, tasks) def overseer_assessment_failed(project, tasks) return nil if project.nil? || tasks.nil? || tasks.empty? + return nil unless notification_email_enabled?(project, 'overseer_failed') add_general @student = project.student @@ -82,4 +84,10 @@ def portfolio_failed(project) subject = "#{project.unit.name}: Portfolio failed to compile" mail(to: email_with_name, from: convenor_email, subject: subject) end + + private + + def notification_email_enabled?(project, kind) + NotificationSetting.for(project.student).delivers?(project.unit, kind, :email) + end end diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 0b55ac3b59..8b7cad664f 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -87,6 +87,24 @@ def test_send_overseer_assessment_failed_email assert mail.html_part.body.include? "projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" end + def test_failed_submission_emails_honour_the_students_email_channels + unit = FactoryBot.create(:unit) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + settings = NotificationSetting.for(project.student) + settings.update!( + channels: settings.channels.merge( + 'overseer_failed' => ['in_app'], + 'pdf_generation_failed' => ['in_app'] + ) + ) + + assert_no_emails do + PortfolioEvidenceMailer.overseer_assessment_failed(project, [task]).deliver_now + PortfolioEvidenceMailer.task_pdf_failed(project, [task]).deliver_now + end + end + def test_send_discussion_deadline_emails unit = FactoryBot.create(:unit) project = unit.active_projects.first diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index 6b40ac0a51..ef7e96f8b3 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -175,6 +175,27 @@ def test_an_unread_overseer_failure_is_carried_by_the_digest assert_not_nil notification.reload.email_sent_at end + def test_opted_out_failure_notifications_are_not_carried_by_the_digest + settings = create_settings + settings.update!( + channels: settings.channels.merge( + 'overseer_failed' => ['in_app'], + 'pdf_generation_failed' => ['in_app'] + ) + ) + overseer_failure = FactoryBot.create(:notification, recipient: settings.user, kind: 'overseer_failed') + pdf_failure = FactoryBot.create(:notification, recipient: settings.user, kind: 'pdf_generation_failed') + + assert_no_emails do + SendNotificationDigestJob.new.perform(settings.id) + end + + [overseer_failure, pdf_failure].each do |notification| + assert_not_nil notification.reload.email_processed_at + assert_nil notification.email_sent_at + end + end + def test_an_unread_discussion_deadline_is_carried_by_the_digest settings = create_settings notification = FactoryBot.create(:notification, recipient: settings.user, kind: 'discuss_warning') From cec23688f76d83d0cd4f97cc228645a88919fa14 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:00:56 +1000 Subject: [PATCH 50/57] chore: bump migration --- ...b => 20260915000025_create_notifications_and_preferences.rb} | 0 db/schema.rb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename db/migrate/{20260903013550_create_notifications_and_preferences.rb => 20260915000025_create_notifications_and_preferences.rb} (100%) diff --git a/db/migrate/20260903013550_create_notifications_and_preferences.rb b/db/migrate/20260915000025_create_notifications_and_preferences.rb similarity index 100% rename from db/migrate/20260903013550_create_notifications_and_preferences.rb rename to db/migrate/20260915000025_create_notifications_and_preferences.rb diff --git a/db/schema.rb b/db/schema.rb index c2c58c0413..979058e3b3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_03_013550) do +ActiveRecord::Schema[8.0].define(version: 2026_09_15_000025) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false From 9c3aa9488822f17f07666f62471108ec0190b5b6 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:12:09 +1000 Subject: [PATCH 51/57] chore: default all settings enabled --- app/models/notification_setting.rb | 8 +------- test/api/notifications_api_test.rb | 2 +- test/models/notification_setting_test.rb | 10 +++++----- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 6d5508b299..736c420e58 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -33,13 +33,7 @@ def self.for(user) def self.default_channels Notification::KINDS.index_with do |kind| - if kind == 'weekly_summary' - [] - elsif Notification::COMMUNICATION_KINDS.include?(kind) - ['in_app'] - else - %w[in_app email] - end + Notification::COMMUNICATION_KINDS.include?(kind) ? ['in_app'] : %w[in_app email] end end diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb index c44a3b8785..fa6d81919c 100644 --- a/test/api/notifications_api_test.rb +++ b/test/api/notifications_api_test.rb @@ -248,7 +248,7 @@ def test_settings_start_from_the_defaults assert_equal @project.campus.timezone, last_response_body['digest_timezone'] assert_equal %w[in_app email], last_response_body.dig('channels', 'new_task_comment') assert_equal %w[in_app email], last_response_body.dig('channels', 'feedback_warning') - assert_empty last_response_body.dig('channels', 'weekly_summary') + assert_equal %w[in_app email], last_response_body.dig('channels', 'weekly_summary') assert_empty last_response_body['units'] end diff --git a/test/models/notification_setting_test.rb b/test/models/notification_setting_test.rb index ec8e4f5f41..236087498c 100644 --- a/test/models/notification_setting_test.rb +++ b/test/models/notification_setting_test.rb @@ -19,7 +19,7 @@ def test_defaults_to_a_monday_morning_digest_on_every_channel_but_push assert_equal NotificationSetting.default_digest_timezone, settings.digest_timezone assert_equal 1, settings.digest_weekday assert_equal %w[in_app email], settings.channels['new_task_comment'] - assert_empty settings.channels['weekly_summary'] + assert_equal %w[in_app email], settings.channels['weekly_summary'] assert_equal ['in_app'], settings.channels['communication_email'] assert_equal Notification::KINDS.sort, settings.channels.keys.sort end @@ -65,14 +65,14 @@ def test_weekly_summary_honours_the_unit_mute assert_not settings.weekly_summary_for?(unit) end - def test_weekly_summary_can_be_opted_into_without_changing_other_notifications + def test_weekly_summary_can_be_opted_out_of_without_changing_other_notifications settings = NotificationSetting.for(FactoryBot.create(:user)) unit = FactoryBot.create(:unit, with_students: false, task_count: 0) - assert_not settings.weekly_summary_for?(unit) - settings.update!(channels: settings.channels.merge('weekly_summary' => %w[in_app email])) - assert settings.weekly_summary_for?(unit) + settings.update!(channels: settings.channels.merge('weekly_summary' => [])) + + assert_not settings.weekly_summary_for?(unit) assert settings.delivers?(unit, 'new_task_comment', :email) end From 222b8f65502e4ee8bc67c6128bf371faec98a773 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:19:53 +1000 Subject: [PATCH 52/57] test: ensure alerts dont send email when disabled --- test/mailers/unit_mail_test.rb | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/mailers/unit_mail_test.rb b/test/mailers/unit_mail_test.rb index 8b7cad664f..4cc242df6f 100644 --- a/test/mailers/unit_mail_test.rb +++ b/test/mailers/unit_mail_test.rb @@ -204,6 +204,72 @@ def test_discuss_timeout_notification_is_created_when_immediate_email_is_off assert_empty SendDiscussTimeoutEmailJob.jobs end + def test_discuss_expiry_notification_is_created_when_immediate_email_is_off + unit = FactoryBot.create( + :unit, + discuss_timeout_enabled: true, + discuss_timeout_warning_days: 7, + discuss_timeout_expire_days: 14 + ) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + task.update!(task_status: TaskStatus.discuss) + task.update!(moved_to_discuss_at: 15.days.ago) + settings = NotificationSetting.for(project.student) + settings.update!(channels: settings.channels.merge('discuss_expired' => ['in_app'])) + + assert_equal 1, unit.notify_discuss_timeouts! + + notification = Notification.find_by!(recipient: project.student, task: task, kind: 'discuss_expired') + assert_nil notification.read_at + assert_not_nil notification.email_processed_at + assert_empty SendDiscussTimeoutEmailJob.jobs + end + + # The job is queued a step ahead of its delivery, so it has to re-read the + # setting rather than trust the one that was true when it was queued. + def test_discuss_timeout_email_job_rechecks_the_setting_before_sending + unit = FactoryBot.create( + :unit, + discuss_timeout_enabled: true, + discuss_timeout_warning_days: 7, + discuss_timeout_expire_days: 14 + ) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + task.update!(task_status: TaskStatus.discuss) + task.update!(moved_to_discuss_at: 8.days.ago) + + assert_equal 1, unit.notify_discuss_timeouts! + queued = SendDiscussTimeoutEmailJob.jobs.shift + + settings = NotificationSetting.for(project.student) + settings.update!(channels: settings.channels.merge('discuss_warning' => ['in_app'])) + + assert_no_emails do + SendDiscussTimeoutEmailJob.new.perform(*queued['args']) + end + end + + def test_discuss_timeout_emails_stop_for_a_muted_unit + unit = FactoryBot.create( + :unit, + discuss_timeout_enabled: true, + discuss_timeout_warning_days: 7, + discuss_timeout_expire_days: 14 + ) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + task.update!(task_status: TaskStatus.discuss) + task.update!(moved_to_discuss_at: 8.days.ago) + NotificationUnitOverride.create!(user: project.student, unit: unit, muted: true) + + assert_equal 1, unit.notify_discuss_timeouts! + + assert_empty Notification.where(recipient: project.student, task: task, kind: 'discuss_warning') + assert_empty SendDiscussTimeoutEmailJob.jobs + end + def test_batch_feedback_updates_unenrolled_students_without_emailing_them unit = FactoryBot.create(:unit) project = unit.active_projects.first From b0725dfc9c38cfe26e40ef14a495b904c680c2ef Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:16:22 +1000 Subject: [PATCH 53/57] fix: stop a failed digest from resending every poll cycle --- app/models/notification_setting.rb | 15 +++- app/sidekiq/send_notification_digest_job.rb | 14 ++-- test/sidekiq/notification_jobs_test.rb | 78 ++++++++++++++++++++- 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 736c420e58..21f080d497 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -90,8 +90,19 @@ def shows_in_app?(unit_id, kind) channels_for_unit_id(unit_id, kind).include?('in_app') end - def advance_digest!(from: Time.current) - update!(last_digest_at: from, next_digest_at: next_occurrence(from)) + # Move the schedule on before a digest is delivered, so a delivery that raises + # cannot leave this setting due for the five minute poll to enqueue again on + # every cycle. Returns false when the slot has already been claimed, which is + # how a retry of the same digest avoids advancing the schedule a second time. + def claim_digest!(from: Time.current) + return false unless next_digest_at.nil? || next_digest_at <= from + + update!(next_digest_at: next_occurrence(from)) + true + end + + def record_digest_sent!(at = Time.current) + update!(last_digest_at: at) end def next_occurrence(from = Time.current) diff --git a/app/sidekiq/send_notification_digest_job.rb b/app/sidekiq/send_notification_digest_job.rb index e685c022de..e04fc21452 100644 --- a/app/sidekiq/send_notification_digest_job.rb +++ b/app/sidekiq/send_notification_digest_job.rb @@ -13,6 +13,11 @@ def perform(setting_id) return if setting.digest_frequency == 'off' now = Time.current + # Claim the slot first. Anything that raises after this point leaves the + # notifications unprocessed, so they roll into the next digest rather than + # this one being enqueued and re-sent every time the poll runs. + setting.claim_digest!(from: now) + ready = setting.user .received_notifications .email_pending @@ -22,12 +27,11 @@ def perform(setting_id) deliverable, skipped = ready.partition { |notification| deliverable?(setting, notification, now) } mark_processed(skipped, now) - if deliverable.any? - NotificationsMailer.notification_digest(setting.user, deliverable).deliver_now - mark_processed(deliverable, now, sent: true) - end + return if deliverable.empty? - setting.advance_digest!(from: now) + NotificationsMailer.notification_digest(setting.user, deliverable).deliver_now + mark_processed(deliverable, now, sent: true) + setting.record_digest_sent!(now) end private diff --git a/test/sidekiq/notification_jobs_test.rb b/test/sidekiq/notification_jobs_test.rb index ef7e96f8b3..73ffd2d54d 100644 --- a/test/sidekiq/notification_jobs_test.rb +++ b/test/sidekiq/notification_jobs_test.rb @@ -1,10 +1,17 @@ require 'test_helper' +require 'minitest/mock' class NotificationJobsTest < ActiveSupport::TestCase include ActionMailer::TestHelper - def create_settings(**attributes) - FactoryBot.create(:notification_setting, next_digest_at: 1.minute.ago, **attributes) + # refresh_next_digest_at rewrites next_digest_at whenever the schedule is + # assigned, so the due time has to be forced back past the callback. + def create_settings(due_at: 1.minute.ago, **attributes) + settings = FactoryBot.create(:notification_setting, **attributes) + # rubocop:disable Rails/SkipsModelValidations + settings.update_column(:next_digest_at, due_at) + # rubocop:enable Rails/SkipsModelValidations + settings.reload end def test_digest_sends_only_the_events_enabled_on_the_email_channel @@ -234,6 +241,73 @@ def test_a_muted_unit_is_processed_without_sending assert_nil notification.email_sent_at end + def test_a_failed_digest_leaves_the_setting_scheduled_rather_than_due + settings = create_settings + notification = FactoryBot.create(:notification, recipient: settings.user) + + NotificationsMailer.stub(:notification_digest, ->(*) { raise Net::SMTPServerBusy, 'mail server unavailable' }) do + assert_raises(Net::SMTPServerBusy) do + SendNotificationDigestJob.new.perform(settings.id) + end + end + + # The five minute poll only enqueues settings that are due. Were this one + # still due it would be re-enqueued, and every cycle would resend the digest. + assert_operator settings.reload.next_digest_at, :>, Time.current + assert_not NotificationSetting.due.exists?(id: settings.id) + + # Nothing was delivered, so the notification waits for the next digest. + assert_nil notification.reload.email_processed_at + assert_nil settings.last_digest_at + end + + def test_retrying_a_failed_digest_does_not_advance_the_schedule_again + settings = create_settings + FactoryBot.create(:notification, recipient: settings.user) + + NotificationsMailer.stub(:notification_digest, ->(*) { raise Net::SMTPServerBusy, 'mail server unavailable' }) do + assert_raises(Net::SMTPServerBusy) { SendNotificationDigestJob.new.perform(settings.id) } + end + after_first_attempt = settings.reload.next_digest_at + + NotificationsMailer.stub(:notification_digest, ->(*) { raise Net::SMTPServerBusy, 'mail server unavailable' }) do + assert_raises(Net::SMTPServerBusy) { SendNotificationDigestJob.new.perform(settings.id) } + end + + assert_equal after_first_attempt, settings.reload.next_digest_at + end + + def test_notifications_held_back_by_a_failed_digest_go_out_on_the_next_one + settings = create_settings + notification = FactoryBot.create(:notification, recipient: settings.user) + + NotificationsMailer.stub(:notification_digest, ->(*) { raise Net::SMTPServerBusy, 'mail server unavailable' }) do + assert_raises(Net::SMTPServerBusy) { SendNotificationDigestJob.new.perform(settings.id) } + end + + # rubocop:disable Rails/SkipsModelValidations + settings.update_column(:next_digest_at, 1.minute.ago) + # rubocop:enable Rails/SkipsModelValidations + + assert_emails 1 do + SendNotificationDigestJob.new.perform(settings.id) + end + + assert_not_nil notification.reload.email_sent_at + assert_not_nil settings.reload.last_digest_at + end + + def test_a_digest_with_nothing_to_send_still_moves_the_schedule_on + settings = create_settings + + assert_no_emails do + SendNotificationDigestJob.new.perform(settings.id) + end + + assert_operator settings.reload.next_digest_at, :>, Time.current + assert_nil settings.last_digest_at + end + def test_pruning_removes_old_read_history_but_retains_unread_events old_read = FactoryBot.create(:notification, read_at: 91.days.ago) old_unread = FactoryBot.create(:notification, created_at: 91.days.ago) From 859d2de4edaad92761225ad5d20ab2af79c11c93 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:28:53 +1000 Subject: [PATCH 54/57] fix: test --- test/sidekiq/feedback_warning_notification_job_test.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/sidekiq/feedback_warning_notification_job_test.rb b/test/sidekiq/feedback_warning_notification_job_test.rb index f8a1bc6366..cf07a07548 100644 --- a/test/sidekiq/feedback_warning_notification_job_test.rb +++ b/test/sidekiq/feedback_warning_notification_job_test.rb @@ -37,9 +37,11 @@ def setup feedback_warning_threshold_days: 5 ) definition = @unit.task_definitions.first - @project = @unit.active_projects.find do |project| - project.target_grade >= definition.target_grade && project.tutorial_for(definition).present? - end + # The factory grades the definition and its projects at random, so pin the + # definition below every project rather than hoping one was generated above + # it. Only the fully enrolled students hold a tutorial. + definition.update!(target_grade: 0) + @project = @unit.active_projects.find { |project| project.tutorial_for(definition).present? } @task = @project.task_for_task_definition(definition) @tutor = FactoryBot.create(:user, :tutor) @tutor_role = @unit.employ_staff(@tutor, Role.tutor) From 2f096249dda1cd45047d0d0a9a7897dbb0235e4d Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:06:51 +1000 Subject: [PATCH 55/57] chore: bump migration --- ...b => 20260924110628_create_notifications_and_preferences.rb} | 0 db/schema.rb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename db/migrate/{20260915000025_create_notifications_and_preferences.rb => 20260924110628_create_notifications_and_preferences.rb} (100%) diff --git a/db/migrate/20260915000025_create_notifications_and_preferences.rb b/db/migrate/20260924110628_create_notifications_and_preferences.rb similarity index 100% rename from db/migrate/20260915000025_create_notifications_and_preferences.rb rename to db/migrate/20260924110628_create_notifications_and_preferences.rb diff --git a/db/schema.rb b/db/schema.rb index e666760be6..53baa9b92d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_16_003706) do +ActiveRecord::Schema[8.0].define(version: 2026_09_24_110628) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false From acb464312bed9ab8304a87c3708a4ea153e045fa Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:34:20 +1000 Subject: [PATCH 56/57] fix: test --- test/sidekiq/scheduled_job_test.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index d065907881..d39a70ab14 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,7 +6,7 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.destroy_all! Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 10, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 12, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count @@ -18,6 +18,8 @@ def test_jobs_are_scheduled assert_equal 1, NotifyDiscussTimeoutJob.jobs.count assert_equal 1, NotifyTaskDeadlinesJob.jobs.count assert_equal 1, NotifyFeedbackWarningsJob.jobs.count + assert_equal 1, PollNotificationDigestsJob.jobs.count + assert_equal 1, PruneNotificationsJob.jobs.count assert_equal 1, SyncLmsIntegrationsJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end From 28cfeb2df6cc605127030a958c1cf56db64ddd20 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:56:20 +1000 Subject: [PATCH 57/57] fix: skip deadline notifications for states that existed at rollout --- app/models/notification.rb | 4 +- app/sidekiq/notify_task_deadlines_job.rb | 23 ++++++- .../task_deadline_notification_job_test.rb | 67 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/models/notification.rb b/app/models/notification.rb index d5665ff366..8135263f9f 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -249,7 +249,7 @@ def self.create_event(**attributes) find_by(recipient: recipient, deduplication_key: deduplication_key) end - def self.refresh_task_deadline_notifications!(now: Time.current) + def self.refresh_task_deadline_notifications!(now: Time.current, started_at: nil) stale = where(kind: TASK_DEADLINE_KINDS).unread.includes(task: [:task_definition, { project: %i[unit campus user] }]) stale.find_each do |notification| mark_read(where(id: notification.id)) unless notification.current_task_deadline?(now: now) @@ -259,6 +259,8 @@ def self.refresh_task_deadline_notifications!(now: Time.current) each_task_deadline_candidate(now: now) do |task| kind = task_deadline_kind(task, now: now) next if kind.nil? + # Already in this state when deadline notifications rolled out, so there is nothing new to announce. + next if started_at && task_deadline_kind(task, now: started_at) == kind notification = create_event( recipient: task.student, diff --git a/app/sidekiq/notify_task_deadlines_job.rb b/app/sidekiq/notify_task_deadlines_job.rb index 4cd876d991..5c751b7bad 100644 --- a/app/sidekiq/notify_task_deadlines_job.rb +++ b/app/sidekiq/notify_task_deadlines_job.rb @@ -3,12 +3,33 @@ class NotifyTaskDeadlinesJob include Sidekiq::Job + ROLLOUT_KEY = 'notifications:task_deadline:rollout_at' + sidekiq_options lock: :until_executed, lock_args_method: ->(_args) { ['notify-task-deadlines'] }, on_conflict: :reject, retry: 1 def perform - Notification.refresh_task_deadline_notifications! + now = Time.current + # The first run only records the rollout time, so tasks already started, due soon or overdue are not backfilled + started_at = rollout_started_at + if started_at.nil? + store_rollout_started_at(now) + return + end + + Notification.refresh_task_deadline_notifications!(now: now, started_at: started_at) + end + + private + + def rollout_started_at + value = Sidekiq.redis { |redis| redis.get(ROLLOUT_KEY) } + Time.zone.parse(value) if value.present? + end + + def store_rollout_started_at(time) + Sidekiq.redis { |redis| redis.set(ROLLOUT_KEY, time.utc.iso8601(6), nx: true) } end end diff --git a/test/sidekiq/task_deadline_notification_job_test.rb b/test/sidekiq/task_deadline_notification_job_test.rb index c3e1289909..fcd5f2c135 100644 --- a/test/sidekiq/task_deadline_notification_job_test.rb +++ b/test/sidekiq/task_deadline_notification_job_test.rb @@ -3,6 +3,25 @@ class TaskDeadlineNotificationJobTest < ActiveSupport::TestCase include ActionMailer::TestHelper + class FakeRedis + attr_reader :store + + def initialize + @store = {} + end + + def get(key) + store[key] + end + + def set(key, value, **options) + return if options[:nx] && store.key?(key) + + store[key] = value + 'OK' + end + end + def setup @now = Time.zone.local(2026, 9, 7, 9) @unit = FactoryBot.create( @@ -180,4 +199,52 @@ def test_creates_the_task_record_when_the_assigned_task_has_not_been_opened task = @project.tasks.find_by!(task_definition: @definition) assert @student.received_notifications.exists?(task: task, kind: 'task_start_now') end + + def test_first_job_run_only_sets_the_rollout_boundary + redis = FakeRedis.new + + with_sidekiq_redis(redis) do + travel_to(@now) { NotifyTaskDeadlinesJob.new.perform } + end + + assert_not @student.received_notifications.exists? + assert redis.store.key?(NotifyTaskDeadlinesJob::ROLLOUT_KEY) + end + + def test_job_only_notifies_deadline_states_reached_after_rollout + redis = FakeRedis.new + + with_sidekiq_redis(redis) do + travel_to(@now) { NotifyTaskDeadlinesJob.new.perform } + travel_to(@now + 1.hour) { NotifyTaskDeadlinesJob.new.perform } + assert_not @student.received_notifications.exists? + + travel_to(@now + 5.days) { NotifyTaskDeadlinesJob.new.perform } + travel_to(@now + 11.days) { NotifyTaskDeadlinesJob.new.perform } + end + + assert_equal %w[task_due_soon task_overdue], @student.received_notifications.order(:created_at).pluck(:kind) + end + + def test_job_does_not_backfill_a_task_already_overdue_at_rollout + @task.update!(target_due_date: (@now - 1.day).to_date) + redis = FakeRedis.new + + with_sidekiq_redis(redis) do + travel_to(@now) { NotifyTaskDeadlinesJob.new.perform } + travel_to(@now + 1.day) { NotifyTaskDeadlinesJob.new.perform } + end + + assert_not @student.received_notifications.exists? + end + + private + + def with_sidekiq_redis(redis) + original = Sidekiq.method(:redis) + Sidekiq.define_singleton_method(:redis) { |&redis_block| redis_block.call(redis) } + yield + ensure + Sidekiq.define_singleton_method(:redis, original) + end end