Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
673fcbc
feat: notifications
b0ink Jul 29, 2026
94546aa
Merge branch '11.0.x' into feature/notifications
b0ink Aug 25, 2026
3fcb258
chore: bump migration
b0ink Aug 25, 2026
85f19f8
fix: read receipt handling via cursors
b0ink Aug 25, 2026
5e693a9
fix: ensure only enrolled students receive notifications
b0ink Aug 25, 2026
8005b77
chore: remove deprecated task feedback ready template
b0ink Aug 25, 2026
dab18a6
chore: remove deprecated discuss timeout email
b0ink Aug 25, 2026
d1cd629
chore: remove conflict comment types
b0ink Aug 25, 2026
806270b
refactor: use new notification settings
b0ink Aug 25, 2026
41a9432
fix: ensure email is sent from no reply account
b0ink Aug 25, 2026
2cf21f1
fix: replace emdash with hyphen
b0ink Aug 25, 2026
34dabbe
feat: improve ui
b0ink Aug 25, 2026
49f4cee
refactor: improve message
b0ink Aug 25, 2026
1d0d371
feat: improve notification wording
b0ink Aug 25, 2026
02cd177
feat: add additional detail
b0ink Aug 25, 2026
83864df
chore: fix tests
b0ink Aug 25, 2026
be398b8
chore: clean up migration:
b0ink Aug 26, 2026
9d575bc
refactor: update schema fields
b0ink Aug 26, 2026
a6ae8b0
refactor: fix overseer workflow
b0ink Aug 26, 2026
7d9f755
chore: rename job
b0ink Aug 26, 2026
71fea4c
feat: add portfolio notifications
b0ink Aug 26, 2026
9f71253
refactor: ensure discussion deadlines behave like overseer notifications
b0ink Aug 26, 2026
27b169b
fix: test
b0ink Aug 26, 2026
5cbaa87
fix: test
b0ink Aug 26, 2026
ed7e15b
chore: fix misleading method
b0ink Aug 26, 2026
a6cac39
refactor: use task definition for heading and notification details as…
b0ink Aug 26, 2026
2dc9935
chore: fix strip
b0ink Aug 26, 2026
ef12098
refactor: sort notifications by recently updated and show timestamps
b0ink Aug 26, 2026
7d047cc
feat: display author name of tutor notes in notification
b0ink Aug 26, 2026
21b1420
test: ensure multiple status changes only shows most recent
b0ink Aug 26, 2026
b84872f
feat: create notifications for communication emails
b0ink Aug 27, 2026
6c5254d
feat: enable option for hourly and custom intervals
b0ink Aug 28, 2026
5a95f93
Merge branch '11.0.x' into feature/notifications
b0ink Aug 28, 2026
af88c29
chore: fix rubocop
b0ink Aug 28, 2026
ccab1a2
fix: timezone
b0ink Aug 31, 2026
a930a11
fix: entity
b0ink Aug 31, 2026
fd6aaf7
feat: improve notification digest ui
b0ink Sep 1, 2026
a9ab37b
chore: improve font
b0ink Sep 1, 2026
5089225
Merge branch '11.0.x' into feature/notifications
b0ink Sep 3, 2026
adcd3ab
chore: bump migration
b0ink Sep 3, 2026
e1fa52a
feat: ensure tutor notes notifications persist until manually marked …
b0ink Sep 3, 2026
741c6d5
Merge branch '11.0.x' into feature/notifications
b0ink Sep 7, 2026
4d21b2d
chore: default interval start time at 7am
b0ink Sep 7, 2026
0c766d8
chore: disable weekly emails
b0ink Sep 7, 2026
1d539ba
chore: remove weekly summary option from notifications
b0ink Sep 7, 2026
d635d86
fix: test
b0ink Sep 7, 2026
545aae6
feat: create notifications for tasks approaching due dates
b0ink Sep 7, 2026
c37c125
fix: template
b0ink Sep 7, 2026
ff1779a
chore: abstract method
b0ink Sep 7, 2026
6d2d4fc
chore: remove deprecated styles
b0ink Sep 7, 2026
7e3ba92
Merge branch '11.0.x' into feature/notifications
b0ink Sep 10, 2026
4316628
feat: create notifications for tasks past warning feedback threshold
b0ink Sep 10, 2026
440abea
feat: replace weekly summary emails into notifications
b0ink Sep 11, 2026
7c74453
feat: ensure urgent emails are not sent if emails disabled
b0ink Sep 13, 2026
722232e
Merge branch '11.0.x' into feature/notifications
b0ink Sep 15, 2026
cec2368
chore: bump migration
b0ink Sep 15, 2026
9c3aa94
chore: default all settings enabled
b0ink Sep 15, 2026
222b8f6
test: ensure alerts dont send email when disabled
b0ink Sep 15, 2026
b0725df
fix: stop a failed digest from resending every poll cycle
b0ink Sep 15, 2026
859d2de
fix: test
b0ink Sep 15, 2026
d5db893
Merge branch '11.0.x' into feature/notifications
b0ink Sep 24, 2026
2f09624
chore: bump migration
b0ink Sep 24, 2026
acb4643
fix: test
b0ink Sep 25, 2026
28cfeb2
fix: skip deadline notifications for states that existed at rollout
b0ink Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .ci-setup/crontab
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class ApiRoot < Grape::API
mount UnitContentsApi
mount UnitsApi
mount TutorNotesApi
mount NotificationsApi

mount D2lIntegrationApi::D2lApi
mount D2lIntegrationApi::OauthPublicApi
Expand Down Expand Up @@ -161,6 +162,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,
Expand Down
19 changes: 19 additions & 0 deletions app/api/entities/notification_setting_entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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 do |settings, _options|
settings.resolved_digest_timezone
end
expose :digest_weekday
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
7 changes: 7 additions & 0 deletions app/api/entities/notification_unit_override_entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Entities
class NotificationUnitOverrideEntity < Grape::Entity
expose :unit_id
expose :muted
expose :channels
end
end
3 changes: 3 additions & 0 deletions app/api/entities/tutor_note_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
200 changes: 200 additions & 0 deletions app/api/notifications_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# frozen_string_literal: true

require 'grape'

class NotificationsApi < Grape::API
helpers AuthenticationHelpers

before do
authenticated?
end

helpers do
def notification_settings
@notification_settings ||= NotificationSetting.for(current_user)
end

def notification_scope
current_user
.received_notifications
.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
# 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_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
).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
# 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_unit_overrides.where.not(unit_id: wanted.map { |unit| unit[:unit_id] }).destroy_all
wanted.each do |unit|
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

def accessible_unit_ids
project_units = current_user.projects.where(enrolled: true).select(:unit_id)
role_units = current_user.unit_roles.select(:unit_id)

Unit.where(id: project_units).or(Unit.where(id: role_units)).pluck(:id)

Check warning on line 84 in app/api/notifications_api.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace 'pluck(:id)' with the more semantic 'ids' method.

See more on https://sonarcloud.io/project/issues?id=doubtfire-lms_doubtfire-api&issues=AaA3NgkvhlsPZBRm43sb&open=AaA3NgkvhlsPZBRm43sb&pullRequest=661
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(shown_in_app(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),
group[:message_subject],
group[:message_body],
group[:weekly_summary]&.to_json
].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,
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, unread_counts_by_unit: unread_group_counts_by_unit }
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 = 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 = Notification.mark_read(scope)
{ count: count }
end

desc 'Get the notification settings for the current user'
get '/notification_settings' do
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 :units, type: Array do
requires :unit_id, type: Integer
requires :muted, type: Boolean
optional :channels, type: Hash
end
end
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_overrides(changes[:units]) if changes.key?(:units)
end

present settings, with: Entities::NotificationSettingEntity
end
end
2 changes: 2 additions & 0 deletions app/api/task_comments_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -267,6 +268,7 @@ class TaskCommentsApi < Grape::API

task_comment = task.comments.find(params[:id])
task_comment.mark_as_unread(current_user)
Notification.reopen_from_comment(task_comment, current_user)

SessionTracker.record_assessment_activity(
action: 'mark-comment-unread',
Expand Down
24 changes: 15 additions & 9 deletions app/api/tutor_notes_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -113,18 +116,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
Expand Down
Loading
Loading