From 5064860967deca8fe61be774a42d913b4f95e66d Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 22 Jul 2026 01:04:39 +1000 Subject: [PATCH 001/247] feat(projects): include task definitions on projects load Add include_task_definitions param to GET /projects and expose task definitions on the project and minimal-unit entities for the cross-unit dashboard. --- app/api/entities/minimal/minimal_unit_entity.rb | 1 + app/api/entities/project_entity.rb | 2 +- app/api/projects_api.rb | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/api/entities/minimal/minimal_unit_entity.rb b/app/api/entities/minimal/minimal_unit_entity.rb index 06f9659f2a..1191d190c7 100644 --- a/app/api/entities/minimal/minimal_unit_entity.rb +++ b/app/api/entities/minimal/minimal_unit_entity.rb @@ -20,6 +20,7 @@ class MinimalUnitEntity < Grape::Entity end expose :active + expose :task_definitions, if: :include_task_definitions expose :grade_values expose :grade_definitions end diff --git a/app/api/entities/project_entity.rb b/app/api/entities/project_entity.rb index ebe150267f..ac4a26c955 100644 --- a/app/api/entities/project_entity.rb +++ b/app/api/entities/project_entity.rb @@ -21,7 +21,7 @@ class ProjectEntity < Grape::Entity expose :task_stats, as: :stats, unless: :for_student - expose :tasks, using: TaskEntity, unless: :summary_only do |project, options| + expose :tasks, using: TaskEntity, if: ->(project, options) { !options[:summary_only] || options[:include_task_definitions] } do |project, options| project.task_details_for_shallow_serializer(options[:user]) end diff --git a/app/api/projects_api.rb b/app/api/projects_api.rb index a895007ff3..6811f8b24b 100644 --- a/app/api/projects_api.rb +++ b/app/api/projects_api.rb @@ -12,12 +12,14 @@ class ProjectsApi < Grape::API desc "Fetches all of the current user's projects" params do optional :include_inactive, type: Boolean, desc: 'Include projects for units that are no longer active?' + optional :include_task_definitions, type: Boolean, desc: 'Include all task definitions with tasks for each project?' end get '/projects' do include_inactive = params[:include_inactive] || false + include_task_definitions = params[:include_task_definitions] || false projects = Project.eager_load(:unit, :user).for_user current_user, include_inactive - present projects, with: Entities::ProjectEntity, for_student: true, summary_only: true, user: current_user + present projects, with: Entities::ProjectEntity, for_student: true, summary_only: true, include_task_definitions: include_task_definitions, user: current_user end desc 'Get project' From a8b5dc654d30808fb5278abaaf63f01e624680dc Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 22 Jul 2026 01:33:29 +1000 Subject: [PATCH 002/247] fix(users): correct notification pref nil-defaults --- app/api/users_api.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/api/users_api.rb b/app/api/users_api.rb index 2900bbfba4..11bec99eba 100644 --- a/app/api/users_api.rb +++ b/app/api/users_api.rb @@ -66,9 +66,12 @@ class UsersApi < Grape::API put '/users/:id' do change_self = (params[:id] == current_user.id) - params[:receive_portfolio_notifications] = true if params.key?(:receive_portfolio_notifications) && params[:receive_portfolio_notifications].nil? - params[:receive_portfolio_notifications] = true if params.key?(:receive_feedback_notifications) && params[:receive_feedback_notifications].nil? - params[:receive_portfolio_notifications] = true if params.key?(:receive_task_notifications) && params[:receive_task_notifications].nil? + # Default notification preferences to true when explicitly sent as null. + # (Previously this wrote the portfolio key three times and read the + # top-level params instead of the nested :user hash, so it never applied.) + %i[receive_task_notifications receive_portfolio_notifications receive_feedback_notifications].each do |pref| + params[:user][pref] = true if params[:user].key?(pref) && params[:user][pref].nil? + end # can only modify if current_user.id is same as :id provided # (i.e., user wants to update their own data) or if update_user token From a4295267ef8d29bf5f5f419daf27642d42c80bd5 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 22 Jul 2026 01:33:35 +1000 Subject: [PATCH 003/247] feat(notifications): add unified notification hub --- app/api/api_root.rb | 6 ++ app/api/entities/notification_entity.rb | 10 ++++ app/api/notifications_api.rb | 59 +++++++++++++++++++ app/mailers/notifications_mailer.rb | 19 ++++++ app/models/notification.rb | 30 ++++++++++ app/models/user.rb | 3 + app/services/notification_service.rb | 52 ++++++++++++++++ app/services/push_notification_service.rb | 24 ++++++++ .../single_notification.html.erb | 12 ++++ .../single_notification.text.erb | 9 +++ .../20260722000001_create_notifications.rb | 15 +++++ db/schema.rb | 15 ++++- 12 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 app/api/entities/notification_entity.rb create mode 100644 app/api/notifications_api.rb create mode 100644 app/models/notification.rb create mode 100644 app/services/notification_service.rb create mode 100644 app/services/push_notification_service.rb create mode 100644 app/views/notifications_mailer/single_notification.html.erb create mode 100644 app/views/notifications_mailer/single_notification.text.erb create mode 100644 db/migrate/20260722000001_create_notifications.rb diff --git a/app/api/api_root.rb b/app/api/api_root.rb index 3dbc682297..2b13296a7b 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -111,6 +111,9 @@ class ApiRoot < Grape::API mount Feedback::FeedbackChipApi + # Notifications feature + mount NotificationsApi + # # Add auth details to all end points # @@ -162,6 +165,9 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to OverseerStepsApi AuthenticationHelpers.add_auth_to TutorNotesApi + # Notifications feature + AuthenticationHelpers.add_auth_to NotificationsApi + add_swagger_documentation \ base_path: nil, doc_version: 'v11.0.0', diff --git a/app/api/entities/notification_entity.rb b/app/api/entities/notification_entity.rb new file mode 100644 index 0000000000..a036d4c689 --- /dev/null +++ b/app/api/entities/notification_entity.rb @@ -0,0 +1,10 @@ +module Entities + class NotificationEntity < Grape::Entity + expose :id + expose :notification_type + expose :message + expose :link + expose :read_at + expose :created_at + end +end diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb new file mode 100644 index 0000000000..b40315e768 --- /dev/null +++ b/app/api/notifications_api.rb @@ -0,0 +1,59 @@ +require 'grape' + +class NotificationsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get the current user notifications' + params do + optional :unread_only, type: Boolean, default: false, desc: 'Only return unread notifications' + end + get '/notifications' do + notifications = current_user.notifications.recent_first + notifications = notifications.unread if params[:unread_only] + + present notifications, with: Entities::NotificationEntity + end + + desc 'Get the current user unread notification count' + get '/notifications/unread_count' do + { count: current_user.notifications.unread.count } + end + + desc 'Mark a notification as read' + params do + requires :id, type: Integer, desc: 'The notification id' + end + put '/notifications/:id/read' do + notification = current_user.notifications.find(params[:id]) + notification.mark_read! + + present notification, with: Entities::NotificationEntity + end + + desc 'Mark all of the current user notifications as read' + put '/notifications/read_all' do + # rubocop:disable Rails/SkipsModelValidations + current_user.notifications.unread.update_all(read_at: Time.zone.now) + # rubocop:enable Rails/SkipsModelValidations + + status 200 + { success: true } + end + + desc 'Delete a notification' + params do + requires :id, type: Integer, desc: 'The notification id' + end + delete '/notifications/:id' do + notification = current_user.notifications.find(params[:id]) + notification.destroy! + + status 200 + { success: true } + end +end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index f4aefa1499..a941808111 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -5,6 +5,25 @@ def add_general @unsubscribe_url = "#{@doubtfire_host}/edit_profile" end + # Sends a single in-system notification as an email. Called by + # NotificationService, which rescues delivery errors so the in-app + # notification is never blocked by a mail problem. + def single_notification(notification) + add_general + + @notification = notification + @user = notification.user + + # No global default sender is configured, so pass one explicitly. Set + # institution[:email_sender] in config for the real address (open decision). + from_address = Doubtfire::Application.config.institution[:email_sender].presence || 'noreply@doubtfire.local' + + email_with_name = %("#{@user.name}" <#{@user.email}>) + subject = "#{@doubtfire_product_name}: New notification" + + mail(to: email_with_name, from: from_address, subject: subject) + end + def weekly_staff_summary(unit_role, summary_stats) return nil if unit_role.nil? diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 0000000000..a1b8d264cd --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,30 @@ +class Notification < ApplicationRecord + belongs_to :user + + # Notification categories. The first three map onto the existing user + # preference columns (receive_task/feedback/portfolio_notifications) so that a + # single category toggle gates every delivery channel (in-app, email, push). + TYPES = %w[task feedback portfolio extension general].freeze + + # Maps a notification type to the user preference column that gates it. + # Types without an entry here are always delivered. + PREFERENCE_FOR_TYPE = { + 'task' => :receive_task_notifications, + 'feedback' => :receive_feedback_notifications, + 'portfolio' => :receive_portfolio_notifications + }.freeze + + validates :notification_type, presence: true, inclusion: { in: TYPES } + validates :message, presence: true, length: { maximum: 500 } + + scope :unread, -> { where(read_at: nil) } + scope :recent_first, -> { order(created_at: :desc) } + + def read? + read_at.present? + end + + def mark_read! + update!(read_at: Time.zone.now) unless read? + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 159b9aab7f..45e6f26462 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -164,6 +164,9 @@ def token_for_text?(a_token, token_type) has_many :marking_sessions, dependent: :destroy + # Notifications feature + has_many :notifications, dependent: :destroy, inverse_of: :user + # Model validations/constraints validates :first_name, presence: true validates :last_name, presence: true diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb new file mode 100644 index 0000000000..c4c9296a6f --- /dev/null +++ b/app/services/notification_service.rb @@ -0,0 +1,52 @@ +# Central entry point for raising a notification. +# +# Creates the in-app record and fans out to the enabled delivery channels +# (email now, push in Stage 4). A single category toggle (the user's +# receive_*_notifications preference) gates every channel: if the category is +# off, the notification is suppressed entirely. Per-channel granularity +# (a type x channel matrix) is deferred to a future iteration. +# +# Usage: +# NotificationService.notify( +# user: project.student, +# type: 'feedback', +# message: "New feedback is ready for #{task_definition.name}.", +# link: "/#/projects/#{project.id}" +# ) +class NotificationService + # Raise a notification for a user. Returns the created Notification, or nil if + # the user's preference suppresses this category. + def self.notify(user:, type:, message:, link: nil) + type = type.to_s + return nil unless deliver_to?(user, type) + + notification = Notification.create!( + user: user, + notification_type: type, + message: message, + link: link + ) + + deliver_email(notification) + PushNotificationService.deliver(notification) + + notification + end + + # Whether the user's category preference allows this notification type. + def self.deliver_to?(user, type) + pref = Notification::PREFERENCE_FOR_TYPE[type.to_s] + return true if pref.nil? # types without a preference are always sent + + user.public_send(pref) + end + + # Email channel. Best-effort: a mail failure must never block the in-app + # notification, so errors are logged and swallowed here. + def self.deliver_email(notification) + NotificationsMailer.single_notification(notification).deliver_later + rescue StandardError => e + Rails.logger.error "Failed to enqueue notification email for user #{notification.user_id}: #{e.message}" + end + private_class_method :deliver_email +end diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb new file mode 100644 index 0000000000..3e6657715a --- /dev/null +++ b/app/services/push_notification_service.rb @@ -0,0 +1,24 @@ +# Web Push delivery channel. +# +# Stubbed until VAPID keys are provisioned in the deploy environment (Stage 4). +# It is safe to call now: deliver is a no-op until keys are configured, so the +# notification fan-out works today without push wired up. +# +# Stage 4 will: +# 1. add a push_subscriptions table (user_id, endpoint, p256dh, auth) +# 2. read DOUBTFIRE_VAPID_PUBLIC_KEY / DOUBTFIRE_VAPID_PRIVATE_KEY +# 3. iterate notification.user.push_subscriptions and send the payload via +# the web-push gem (WebPush.payload_send) +class PushNotificationService + def self.deliver(notification) + return unless configured? + + # TODO(Stage 4): send to notification.user.push_subscriptions via web-push. + Rails.logger.debug "Push channel not yet wired for notification #{notification.id}" + end + + # True once VAPID keys are configured. Keeps the fan-out safe to call today. + def self.configured? + ENV['DOUBTFIRE_VAPID_PUBLIC_KEY'].present? && ENV['DOUBTFIRE_VAPID_PRIVATE_KEY'].present? + end +end diff --git a/app/views/notifications_mailer/single_notification.html.erb b/app/views/notifications_mailer/single_notification.html.erb new file mode 100644 index 0000000000..0538d63457 --- /dev/null +++ b/app/views/notifications_mailer/single_notification.html.erb @@ -0,0 +1,12 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

View in <%= @doubtfire_product_name %>

+<% end %> + +

+ You are receiving this because your notification preferences allow it. + You can update them at your profile. +

diff --git a/app/views/notifications_mailer/single_notification.text.erb b/app/views/notifications_mailer/single_notification.text.erb new file mode 100644 index 0000000000..af0c03b196 --- /dev/null +++ b/app/views/notifications_mailer/single_notification.text.erb @@ -0,0 +1,9 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> +<% if @notification.link.present? -%> + +View it here: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can update your notification preferences at <%= @unsubscribe_url %>. diff --git a/db/migrate/20260722000001_create_notifications.rb b/db/migrate/20260722000001_create_notifications.rb new file mode 100644 index 0000000000..f935c74ed8 --- /dev/null +++ b/db/migrate/20260722000001_create_notifications.rb @@ -0,0 +1,15 @@ +class CreateNotifications < ActiveRecord::Migration[8.0] + def change + create_table :notifications do |t| + t.references :user, foreign_key: true, null: false + t.string :notification_type, null: false + t.string :message, null: false + t.string :link + t.datetime :read_at + + t.timestamps + end + + add_index :notifications, [:user_id, :read_at] + end +end diff --git a/db/schema.rb b/db/schema.rb index b8ec5659b3..6582df2425 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_09_014859) do +ActiveRecord::Schema[8.0].define(version: 2026_07_22_000001) 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 @@ -340,6 +340,18 @@ t.index ["task_id"], name: "index_moderated_tasks_on_task_id" end + create_table "notifications", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "notification_type", null: false + t.string "message", null: false + t.string "link" + t.datetime "read_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "read_at"], name: "index_notifications_on_user_id_and_read_at" + t.index ["user_id"], name: "index_notifications_on_user_id" + 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 @@ -991,6 +1003,7 @@ 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 "notifications", "users" add_foreign_key "user_oauth_states", "users" add_foreign_key "user_oauth_tokens", "users" end From 0dcb9a3cf3b2288f113b63800dd9872eb6b09698 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Wed, 22 Jul 2026 01:33:41 +1000 Subject: [PATCH 004/247] docs(notifications): add hub and status docs --- NOTIFICATIONS.md | 88 +++++++++++++++++++++++++++++++ NOTIFICATIONS_STATUS.md | 113 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 NOTIFICATIONS.md create mode 100644 NOTIFICATIONS_STATUS.md diff --git a/NOTIFICATIONS.md b/NOTIFICATIONS.md new file mode 100644 index 0000000000..ff3c2181e5 --- /dev/null +++ b/NOTIFICATIONS.md @@ -0,0 +1,88 @@ +# Notifications + +This explains how notifications work in OnTrack after the unification. + +## The idea + +Before, each type of message did its own thing. Emails were sent from many +places. There was no single system. + +Now there is one system. You send a notification once. It goes out on all the +channels the user has turned on. Today those channels are in-app and email. Push +is planned next. + +## The flow + + something happens in the app + -> you call NotificationService.notify(...) + -> saves an in-app notification (the bell) + -> sends an email + -> sends a push (later, off for now) + +You only call one thing. The system handles the rest. + +## How to send one + +Call this from anywhere in the API code: + + NotificationService.notify( + user: project.student, + type: 'feedback', + message: "New feedback is ready for #{task_definition.name}.", + link: "/#/projects/#{project.id}" + ) + +- user: who gets it. +- type: the category. One of task, feedback, portfolio, extension, general. +- message: the text the user sees. Keep it short. +- link: where clicking it should take them. Optional. + +## Types and preferences + +Each user already has three on/off settings in their profile: + +- receive_task_notifications +- receive_feedback_notifications +- receive_portfolio_notifications + +The type you pass maps to one of these settings. + +- task uses receive_task_notifications +- feedback uses receive_feedback_notifications +- portfolio uses receive_portfolio_notifications +- extension and general are always sent + +If the matching setting is off, nothing is sent. Not the bell, not the email, +not the push. One switch controls all channels. This keeps it simple. We can add +per-channel switches later if we want. + +## The pieces + +- app/models/notification.rb: the notification record. Has the type, message, + link, and whether it has been read. +- app/services/notification_service.rb: the one entry point. Checks the setting, + saves the record, sends the email, calls push. +- app/services/push_notification_service.rb: the push channel. It is a safe + placeholder for now. It does nothing until push keys are set up. +- app/mailers/notifications_mailer.rb: the email. New method single_notification + with templates in app/views/notifications_mailer. +- app/api/notifications_api.rb: the endpoints the web app calls. +- app/api/entities/notification_entity.rb: the shape of the data sent back. + +## The endpoints + + GET /api/notifications list my notifications + GET /api/notifications/unread_count how many I have not read + PUT /api/notifications/:id/read mark one as read + PUT /api/notifications/read_all mark all as read + DELETE /api/notifications/:id delete one + +Every endpoint only ever touches the current user's own notifications. + +## What is on now + +- In-app: working. The record is saved and the endpoints return it. +- Email: working. Best effort. If email fails, the in-app notification is still + saved. +- Push: not on yet. The code path is there but does nothing until push keys are + set up. diff --git a/NOTIFICATIONS_STATUS.md b/NOTIFICATIONS_STATUS.md new file mode 100644 index 0000000000..6c24ee386c --- /dev/null +++ b/NOTIFICATIONS_STATUS.md @@ -0,0 +1,113 @@ +# Unified Notifications - Status + +Feature: unified notifications (in-app, email, push) for OnTrack. +Base: `11.0.x`. Branch: `feature/notifications` (api and web), off `origin/11.0.x`. +Merge and demo target: `integration`. + +The lead runs all commits, merges, and pushes. This file records what is staged +in the working tree and the exact commands to run. + +## Architecture + +One hub, many channels: + + event happens -> NotificationService.notify(...) -> in-app record + -> email (existing mailer) + -> push (Stage 4, stubbed now) + +A single category toggle gates every channel. The three existing user +preference columns (`receive_task_notifications`, `receive_feedback_notifications`, +`receive_portfolio_notifications`) map onto the notification `type`. If a +category is off, the notification is suppressed on all channels, including +in-app. Per-channel granularity (a type x channel matrix) is deferred to v2. + +## Stage 1 (done, staged in api working tree) + +New files: +- `app/models/notification.rb` - hub model. Types task/feedback/portfolio/extension/general. `unread` and `recent_first` scopes, `mark_read!`. +- `db/migrate/20260722000001_create_notifications.rb` - notifications table (user_id, notification_type, message, link, read_at, timestamps). +- `app/services/notification_service.rb` - the fan-out entry point. Respects the category preference, creates the in-app record, sends email, calls push. +- `app/services/push_notification_service.rb` - push channel stub. No-op until VAPID keys exist, so it is safe to call today. +- `app/api/notifications_api.rb` - REST endpoints (list, unread_count, mark read, mark all read, delete). All scoped to `current_user`, so no IDOR. +- `app/api/entities/notification_entity.rb` - response shape. +- `app/views/notifications_mailer/single_notification.{html,text}.erb` - email templates. + +Changed files: +- `app/api/api_root.rb` - mount `NotificationsApi` and add auth, both in a `# Notifications feature` block. +- `app/models/user.rb` - `has_many :notifications` in a `# Notifications feature` block. +- `app/mailers/notifications_mailer.rb` - new `single_notification` method. +- `app/api/users_api.rb` - real bug fix (see below). + +## Bug review result + +- `users_api.rb:69-71` copy-paste bug: REAL, fixed. The three lines all wrote the + portfolio key and read top-level params instead of the nested `:user` hash, so + the nil-default never applied. Replaced with a loop over the three keys on + `params[:user]`. +- "portfolio emails gated by the wrong flag": NOT a bug. `receive_portfolio_notifications` + is enforced at `lib/tasks/generate_pdfs.rake:151`, which gates the + `portfolio_ready` and `portfolio_failed` emails. Line 75 of + `portfolio_evidence.rb` gates a task email (`task_pdf_failed`) by the task flag, + which is correct. No change made here. + +## Endpoints + + GET /api/notifications?unread_only=false + GET /api/notifications/unread_count + PUT /api/notifications/:id/read + PUT /api/notifications/read_all + DELETE /api/notifications/:id + +## How to raise a notification (for teammates wiring events) + + NotificationService.notify( + user: project.student, + type: 'feedback', + message: "New feedback is ready for #{task_definition.name}.", + link: "/#/projects/#{project.id}" + ) + +## Verification (run in the container, host Ruby is 2.6) + +The compose files live in `doubtfire-deploy/development/`. Run all of these from +there. See doubtfire-deploy/RUNNING-LOCALLY.md. + + cd doubtfire-deploy/development + COMPOSE="docker compose -f docker-compose.yml -f docker-compose.local-paths.yml" + +1. Rebuild and start (11.0.x needs Ruby 3.4 and Node 22): + `$COMPOSE up -d --build` +2. Only if migrate fails with a stale-DB error (task_prerequisites doesn't exist), reset first: + `$COMPOSE run --rm --no-deps doubtfire-api bash -c "bundle exec rake db:drop db:create db:schema:load && bundle exec rails db:environment:set RAILS_ENV=development && bundle exec rake db:populate"` +3. Migrate (updates `db/schema.rb`, commit that change after migrating): + `$COMPOSE exec doubtfire-api bundle exec rails db:migrate` +4. Lint the new code: + `$COMPOSE exec doubtfire-api bundle exec rubocop app/models/notification.rb app/services app/api/notifications_api.rb app/api/entities/notification_entity.rb` +5. Smoke test: in a rails console, `NotificationService.notify(user: User.first, type: 'general', message: 'Hello')`, then `GET /api/notifications` as that user. + +Still to add for Stage 1 completion (good first tasks, run in container): +- `test/factories/notifications_factory.rb` +- `test/models/notification_test.rb` and `test/api/notifications_api_test.rb` + +## Open decisions for the lead + +1. VAPID keys: where in `doubtfire-deploy` secrets, and who generates them. Suggest + env vars `DOUBTFIRE_VAPID_PUBLIC_KEY` and `DOUBTFIRE_VAPID_PRIVATE_KEY`. Blocks + the Stage 4 push send path. Push code is safe to run before this is set. +2. Notification email sender: set `institution[:email_sender]` in config, or accept + the `noreply@doubtfire.local` fallback for now. +3. `api_root.rb` mount ordering: agree with the cross-unit and peer-progress leads. +4. In-app suppression when a category is off is implemented as decided. Confirm. +5. v1 trigger events: which events create notifications. The mechanism is done; + this is a scoping task for the team. + +## Remaining stages + +- Stage 2 (web): re-home the salvaged #353 header bell to Angular 22 (standalone: false, + routerLink not uiSref, @if/@for), add a notifications API service, register in a + `// Notifications feature` block in `doubtfire-angular.module.ts`. +- Stage 3 (web): settings toggles for the three preference booleans (API already exists). +- Stage 4 (api + web + deploy): Web Push. push_subscriptions table and endpoint, + web-push gem, VAPID keys; SwPush subscribe and permission UI; service worker + push and notificationclick handlers. +- Stage 5: verification per stage. From 0c642e0a3c5400a5b21e0141cd59ef59be91dad8 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 2 Aug 2026 13:28:25 +1000 Subject: [PATCH 005/247] feat(notifications): add event column and send emails synchronously --- NOTIFICATIONS.md | 12 +- NOTIFICATIONS_STATUS.md | 8 +- app/models/notification.rb | 7 ++ app/services/notification_service.rb | 28 ++++- ...260802000001_add_event_to_notifications.rb | 21 ++++ ...002_change_notification_message_to_text.rb | 17 +++ db/schema.rb | 6 +- test/services/notification_service_test.rb | 110 ++++++++++++++++++ 8 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260802000001_add_event_to_notifications.rb create mode 100644 db/migrate/20260802000002_change_notification_message_to_text.rb create mode 100644 test/services/notification_service_test.rb diff --git a/NOTIFICATIONS.md b/NOTIFICATIONS.md index ff3c2181e5..0946d647b4 100644 --- a/NOTIFICATIONS.md +++ b/NOTIFICATIONS.md @@ -28,15 +28,25 @@ Call this from anywhere in the API code: NotificationService.notify( user: project.student, type: 'feedback', + event: 'task_comment_created', message: "New feedback is ready for #{task_definition.name}.", link: "/#/projects/#{project.id}" ) - user: who gets it. - type: the category. One of task, feedback, portfolio, extension, general. -- message: the text the user sees. Keep it short. + This is what the user's on/off setting controls. +- event: the specific thing that happened, as a lower_snake_case string. + Required. Use one event name per ticket, and use the same name every time you + raise that notification, so a notification can always be traced back to the + code that sent it. +- message: the text the user sees. Keep it short, 500 characters at most. - link: where clicking it should take them. Optional. +type and event are different on purpose. type is the coarse category the user +switches off in their profile. event is the fine-grained reason, and there will +be many events inside one type. + ## Types and preferences Each user already has three on/off settings in their profile: diff --git a/NOTIFICATIONS_STATUS.md b/NOTIFICATIONS_STATUS.md index 6c24ee386c..844e52038e 100644 --- a/NOTIFICATIONS_STATUS.md +++ b/NOTIFICATIONS_STATUS.md @@ -25,7 +25,7 @@ in-app. Per-channel granularity (a type x channel matrix) is deferred to v2. New files: - `app/models/notification.rb` - hub model. Types task/feedback/portfolio/extension/general. `unread` and `recent_first` scopes, `mark_read!`. -- `db/migrate/20260722000001_create_notifications.rb` - notifications table (user_id, notification_type, message, link, read_at, timestamps). +- `db/migrate/20260722000001_create_notifications.rb` - notifications table (user_id, notification_type, message, link, read_at, timestamps). Ticket EN-F01 later added `event` and widened `message` to text. - `app/services/notification_service.rb` - the fan-out entry point. Respects the category preference, creates the in-app record, sends email, calls push. - `app/services/push_notification_service.rb` - push channel stub. No-op until VAPID keys exist, so it is safe to call today. - `app/api/notifications_api.rb` - REST endpoints (list, unread_count, mark read, mark all read, delete). All scoped to `current_user`, so no IDOR. @@ -63,10 +63,14 @@ Changed files: NotificationService.notify( user: project.student, type: 'feedback', + event: 'task_comment_created', message: "New feedback is ready for #{task_definition.name}.", link: "/#/projects/#{project.id}" ) +`event:` is required. It is the specific thing that happened, in +lower_snake_case. See NOTIFICATIONS.md for how it differs from `type:`. + ## Verification (run in the container, host Ruby is 2.6) The compose files live in `doubtfire-deploy/development/`. Run all of these from @@ -83,7 +87,7 @@ there. See doubtfire-deploy/RUNNING-LOCALLY.md. `$COMPOSE exec doubtfire-api bundle exec rails db:migrate` 4. Lint the new code: `$COMPOSE exec doubtfire-api bundle exec rubocop app/models/notification.rb app/services app/api/notifications_api.rb app/api/entities/notification_entity.rb` -5. Smoke test: in a rails console, `NotificationService.notify(user: User.first, type: 'general', message: 'Hello')`, then `GET /api/notifications` as that user. +5. Smoke test: in a rails console, `NotificationService.notify(user: User.first, type: 'general', event: 'smoke_test', message: 'Hello')`, then `GET /api/notifications` as that user. A mail file should also appear in `doubtfire-deploy/data/tmp/mails/`. Still to add for Stage 1 completion (good first tasks, run in container): - `test/factories/notifications_factory.rb` diff --git a/app/models/notification.rb b/app/models/notification.rb index a1b8d264cd..23d846a56d 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -6,6 +6,12 @@ class Notification < ApplicationRecord # single category toggle gates every delivery channel (in-app, email, push). TYPES = %w[task feedback portfolio extension general].freeze + # `notification_type` is the category the user's preferences switch on. + # `event` is the specific thing that happened within that category, e.g. + # 'task_comment_created'. It is free text so a new event ticket does not have + # to edit this model, but it is required so every notification can be traced + # back to the code that raised it. + # Maps a notification type to the user preference column that gates it. # Types without an entry here are always delivered. PREFERENCE_FOR_TYPE = { @@ -15,6 +21,7 @@ class Notification < ApplicationRecord }.freeze validates :notification_type, presence: true, inclusion: { in: TYPES } + validates :event, presence: true, length: { maximum: 255 } validates :message, presence: true, length: { maximum: 500 } scope :unread, -> { where(read_at: nil) } diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index c4c9296a6f..aa18cc5c5f 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -10,19 +10,26 @@ # NotificationService.notify( # user: project.student, # type: 'feedback', +# event: 'task_comment_created', # message: "New feedback is ready for #{task_definition.name}.", # link: "/#/projects/#{project.id}" # ) class NotificationService # Raise a notification for a user. Returns the created Notification, or nil if # the user's preference suppresses this category. - def self.notify(user:, type:, message:, link: nil) + # + # type - the category the user's preference switches on, one of + # Notification::TYPES. + # event - the specific thing that happened, e.g. 'task_comment_created'. + # Required, so every notification can be traced back to its source. + def self.notify(user:, type:, event:, message:, link: nil) type = type.to_s return nil unless deliver_to?(user, type) notification = Notification.create!( user: user, notification_type: type, + event: event.to_s, message: message, link: link ) @@ -43,10 +50,25 @@ def self.deliver_to?(user, type) # Email channel. Best-effort: a mail failure must never block the in-app # notification, so errors are logged and swallowed here. + # + # Sent inline, rather than with deliver_later or a Sidekiq job. + # + # No Active Job queue adapter is configured, so deliver_later would run on + # Active Job's in-process :async thread pool. That does execute, but only in + # memory: anything still pending is lost when the container restarts, and it + # shows up in no dashboard. A Sidekiq job would be worse in development, where + # the stack starts Redis but runs no worker process at all, so perform_async + # would queue to Redis and sit there forever without reporting an error. + # + # Known trade-off: this runs on the request path, and production delivers over + # SMTP (config/environments/production.rb), so a slow mail server slows down + # whatever action raised the notification. The rescue below cannot prevent that + # latency, and it also swallows the failure without retrying. Moving this onto + # a real queue is ticket EN-F03, which adds the worker service first. def self.deliver_email(notification) - NotificationsMailer.single_notification(notification).deliver_later + NotificationsMailer.single_notification(notification).deliver_now rescue StandardError => e - Rails.logger.error "Failed to enqueue notification email for user #{notification.user_id}: #{e.message}" + Rails.logger.error "Failed to send notification email for user #{notification.user_id}: #{e.message}" end private_class_method :deliver_email end diff --git a/db/migrate/20260802000001_add_event_to_notifications.rb b/db/migrate/20260802000001_add_event_to_notifications.rb new file mode 100644 index 0000000000..1209aed464 --- /dev/null +++ b/db/migrate/20260802000001_add_event_to_notifications.rb @@ -0,0 +1,21 @@ +class AddEventToNotifications < ActiveRecord::Migration[8.0] + # `notification_type` is the broad category the user's preferences switch on + # (task, feedback, portfolio, extension, general). `event` records which + # specific thing happened, so a notification can be traced back to the code + # that raised it and so we can later suppress or batch a single event without + # turning off the whole category. + def up + # Added with a placeholder default first, so the ALTER succeeds on a + # database that already has notification rows, then the default is dropped + # so new records must supply a real event. + add_column :notifications, :event, :string, null: false, default: 'legacy' + change_column_default :notifications, :event, nil + + add_index :notifications, [:user_id, :event] + end + + def down + remove_index :notifications, column: [:user_id, :event] + remove_column :notifications, :event + end +end diff --git a/db/migrate/20260802000002_change_notification_message_to_text.rb b/db/migrate/20260802000002_change_notification_message_to_text.rb new file mode 100644 index 0000000000..2a5bf739d1 --- /dev/null +++ b/db/migrate/20260802000002_change_notification_message_to_text.rb @@ -0,0 +1,17 @@ +class ChangeNotificationMessageToText < ActiveRecord::Migration[8.0] + # The model allows a 500 character message, but the column was created as a + # string, which MariaDB stores as VARCHAR(255). Anything over 255 characters + # passed validation and then failed at the database. TEXT holds the full + # validated length. + def up + change_column :notifications, :message, :text, null: false + end + + # Lossy. Narrowing back to VARCHAR(255) will reject (strict mode) or silently + # truncate (permissive mode) any message over 255 characters saved while the + # column was text. Only roll this back on a database you are willing to lose + # long messages from. + def down + change_column :notifications, :message, :string, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 6582df2425..4df9383bd3 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_22_000001) do +ActiveRecord::Schema[8.0].define(version: 2026_08_02_000002) 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 @@ -343,11 +343,13 @@ create_table "notifications", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "user_id", null: false t.string "notification_type", null: false - t.string "message", null: false + t.text "message", null: false t.string "link" t.datetime "read_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "event", null: false + t.index ["user_id", "event"], name: "index_notifications_on_user_id_and_event" t.index ["user_id", "read_at"], name: "index_notifications_on_user_id_and_read_at" t.index ["user_id"], name: "index_notifications_on_user_id" end diff --git a/test/services/notification_service_test.rb b/test/services/notification_service_test.rb new file mode 100644 index 0000000000..5ee7e5e7a6 --- /dev/null +++ b/test/services/notification_service_test.rb @@ -0,0 +1,110 @@ +require 'test_helper' +require 'minitest/mock' + +class NotificationServiceTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + end + + def test_notify_creates_a_notification_and_sends_one_email + user = FactoryBot.create(:user) + + notification = NotificationService.notify( + user: user, + type: 'task', + event: 'task_comment_created', + message: 'Your tutor commented on your task.', + link: "/projects/#{user.id}" + ) + + assert notification.persisted? + assert_equal 'task', notification.notification_type + assert_equal 'task_comment_created', notification.event + + # deliver_now, so the mail is in deliveries immediately without any queue + # being drained. This is what breaks if someone puts deliver_later back. + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [user.email], ActionMailer::Base.deliveries.last.to + end + + def test_notify_requires_an_event_keyword + user = FactoryBot.create(:user) + + assert_raises ArgumentError do + NotificationService.notify(user: user, type: 'general', message: 'No event given.') + end + end + + def test_blank_event_is_rejected + user = FactoryBot.create(:user) + + assert_no_difference 'Notification.count' do + assert_raises ActiveRecord::RecordInvalid do + NotificationService.notify(user: user, type: 'general', event: '', message: 'Blank event.') + end + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_a_symbol_event_is_stored_as_a_string + user = FactoryBot.create(:user) + + notification = NotificationService.notify( + user: user, type: 'general', event: :task_comment_created, message: 'Symbol event.' + ) + + assert_equal 'task_comment_created', notification.event + end + + def test_message_at_the_validated_maximum_survives_a_round_trip + user = FactoryBot.create(:user) + long_message = 'a' * 500 + + notification = NotificationService.notify( + user: user, type: 'general', event: 'long_message_check', message: long_message + ) + + # Fails before the message column became text: 500 characters passed + # validation and were then truncated or rejected by VARCHAR(255). + assert_equal 500, notification.reload.message.length + end + + def test_notification_is_suppressed_when_the_category_preference_is_off + user = FactoryBot.create(:user, receive_feedback_notifications: false) + + assert_no_difference 'Notification.count' do + result = NotificationService.notify( + user: user, type: 'feedback', event: 'task_comment_created', message: 'Suppressed.' + ) + assert_nil result + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_types_without_a_preference_are_always_delivered + user = FactoryBot.create(:user, receive_task_notifications: false, receive_feedback_notifications: false, receive_portfolio_notifications: false) + + notification = NotificationService.notify( + user: user, type: 'general', event: 'always_sent', message: 'General notice.' + ) + + assert notification.persisted? + assert_equal 1, ActionMailer::Base.deliveries.count + end + + def test_a_mail_failure_does_not_block_the_in_app_notification + user = FactoryBot.create(:user) + + NotificationsMailer.stub :single_notification, ->(_n) { raise StandardError, 'smtp exploded' } do + notification = NotificationService.notify( + user: user, type: 'general', event: 'mail_failure_check', message: 'Still saved.' + ) + + assert notification.persisted? + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end +end From 9fa75666388c0fe21358f45f628f00c3872a2b3b Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 2 Aug 2026 13:39:43 +1000 Subject: [PATCH 006/247] feat(notifications): email on new task comment --- app/mailers/notifications_mailer.rb | 22 +++- app/models/task.rb | 30 +++++ .../task_comment_created.html.erb | 17 +++ .../task_comment_created.text.erb | 11 ++ .../events/task_comment_created.md | 81 ++++++++++++ test/models/notification_task_comment_test.rb | 123 ++++++++++++++++++ 6 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 app/views/notifications_mailer/task_comment_created.html.erb create mode 100644 app/views/notifications_mailer/task_comment_created.text.erb create mode 100644 docs/notifications/events/task_comment_created.md create mode 100644 test/models/notification_task_comment_test.rb diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index a941808111..eb9d9c6dd6 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -21,7 +21,27 @@ def single_notification(notification) email_with_name = %("#{@user.name}" <#{@user.email}>) subject = "#{@doubtfire_product_name}: New notification" - mail(to: email_with_name, from: from_address, subject: subject) + # An event may ship its own pair of templates named after it, for example + # task_comment_created.html.erb and task_comment_created.text.erb. Events + # without them fall back to the generic single_notification pair. + # + # This is why a new event ticket only ever adds files and never edits this + # method: eight event tickets can run in parallel without touching each + # other's work. + mail( + to: email_with_name, + from: from_address, + subject: subject, + template_name: event_template_name(notification.event) + ) + end + + # The event's own template if it exists, otherwise the generic one. + def event_template_name(event) + return 'single_notification' if event.blank? + return 'single_notification' unless lookup_context.exists?(event, [self.class.mailer_name], false) + + event end def weekly_staff_summary(unit_role, summary_stats) diff --git a/app/models/task.rb b/app/models/task.rb index a48148c8a3..9bd0dbf1e2 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -946,9 +946,39 @@ def add_text_comment(user, text, reply_to_id = nil) comment.reply_to_id = reply_to_id comment.save! + notify_comment_recipient(comment) + comment end + # Tell the other party that a comment arrived. + # + # comment.recipient is already worked out above: the tutor when a student + # commented, the student when a tutor commented. Do not recalculate it. + # + # A project with no tutor for this task definition has no recipient, so the + # guard is required and not defensive padding. + # + # The comment text is deliberately not put in the notification. The email is a + # prompt to come back to OnTrack, not a copy of the conversation. + # + # Raising a notification must never stop a comment being posted, so failures + # are logged and swallowed. NotificationService already rescues mail errors; + # this catches the record write and anything else unexpected. + def notify_comment_recipient(comment) + return if comment.recipient.blank? + + NotificationService.notify( + user: comment.recipient, + type: 'feedback', + event: 'task_comment_created', + message: "#{comment.user.name} commented on #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise task_comment_created notification for task #{id}: #{e.message}" + end + def individual_task_or_submitter_of_group_task? return true if !group_task? # its individual return true if group.blank? # no group yet... so individual diff --git a/app/views/notifications_mailer/task_comment_created.html.erb b/app/views/notifications_mailer/task_comment_created.html.erb new file mode 100644 index 0000000000..3ddbeb7a26 --- /dev/null +++ b/app/views/notifications_mailer/task_comment_created.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The comment is not included in this email. Open the task in + <%= @doubtfire_product_name %> to read it and reply. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your feedback notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_comment_created.text.erb b/app/views/notifications_mailer/task_comment_created.text.erb new file mode 100644 index 0000000000..375494cc38 --- /dev/null +++ b/app/views/notifications_mailer/task_comment_created.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The comment is not included in this email. Open the task in <%= @doubtfire_product_name %> to read it and reply. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/docs/notifications/events/task_comment_created.md b/docs/notifications/events/task_comment_created.md new file mode 100644 index 0000000000..cd2b208474 --- /dev/null +++ b/docs/notifications/events/task_comment_created.md @@ -0,0 +1,81 @@ +# Event: task_comment_created + +The first notification event wired into OnTrack. Ticket EN-E01. + +This is the worked example. If you are adding an event, copy the shape of this +one. + +## What it does + +Someone posts a text comment on a task. The other party is told. + +- A tutor comments, the student is emailed. +- A student comments, the tutor is emailed. + +## Where it is raised + +`app/models/task.rb`, in `notify_comment_recipient`, called at the end of +`add_text_comment` once the comment has saved. + + NotificationService.notify( + user: comment.recipient, + type: 'feedback', + event: 'task_comment_created', + message: "#{comment.user.name} commented on #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + +## Fields + +| Field | Value | +|---|---| +| `type` | `feedback`, so the recipient's `receive_feedback_notifications` switch controls it | +| `event` | `task_comment_created` | +| `message` | Who commented, which task, which unit. Never the comment text | +| `link` | `/projects//dashboard/` | + +## Templates + +- `app/views/notifications_mailer/task_comment_created.text.erb` +- `app/views/notifications_mailer/task_comment_created.html.erb` + +`NotificationsMailer#single_notification` picks the template named after the +event when it exists, and falls back to `single_notification.*.erb` when it does +not. That is why adding an event never requires editing the mailer. + +## Three things to know before you copy this + +1. **Do not work out who to notify.** `comment.recipient` is already set by + `add_text_comment`: the tutor when a student commented, the student when a + tutor commented. + +2. **Guard for no recipient.** A project with no tutor for the task definition + has no recipient. `notify_comment_recipient` returns early. Without that it + raises. + +3. **A notification must never break the thing that triggered it.** The call is + wrapped in a `rescue StandardError` that logs and swallows. Posting a comment + must succeed even if notifying fails. + +## How to check it by hand + +1. Sign in as a tutor, open a student's task, post a comment. +2. A file appears in `doubtfire-deploy/data/tmp/mails/`, addressed to the + student. It names the commenter and the task, and does not contain the + comment text. +3. Turn that student's feedback notifications off in their profile, comment + again, and no new file appears. + +## Tests + +`test/models/notification_task_comment_test.rb` + +Covers both directions, the preference switch, the absent recipient, the comment +text staying out of the email, and that a notification failure still leaves the +comment saved. + +## Known limitation + +The email subject is the generic "New notification". Per-event subjects would +need a shared lookup in the mailer, which would make every event ticket edit the +same file and collide. Left as it is on purpose. diff --git a/test/models/notification_task_comment_test.rb b/test/models/notification_task_comment_test.rb new file mode 100644 index 0000000000..48db8a6ff0 --- /dev/null +++ b/test/models/notification_task_comment_test.rb @@ -0,0 +1,123 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-E01: posting a task comment notifies the other party. +class NotificationTaskCommentTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task = @project.task_for_task_definition(@task_definition) + @student = @project.student + @tutor = @project.tutor_for(@task_definition) + end + + # The notification email is multipart, and Mail::Body#to_s is empty for a + # multipart body. Reading it the naive way makes every refute_includes pass + # for the wrong reason, so decode the parts instead. + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_a_tutor_comment_notifies_the_student + assert_difference 'Notification.count', 1 do + @task.add_text_comment(@tutor, 'Have a look at question three.') + end + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'feedback', notification.notification_type + assert_equal 'task_comment_created', notification.event + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_a_student_comment_notifies_the_tutor + assert_difference 'Notification.count', 1 do + @task.add_text_comment(@student, 'I am stuck on question three.') + end + + notification = Notification.recent_first.first + + # The recipient is the other party, never the person who commented. + assert_equal @tutor, notification.user + assert_not_equal @student, notification.user + assert_equal [@tutor.email], ActionMailer::Base.deliveries.last.to + end + + def test_no_notification_when_the_feedback_preference_is_off + @student.update!(receive_feedback_notifications: false) + + assert_no_difference 'Notification.count' do + @task.add_text_comment(@tutor, 'You will not be told about this.') + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_the_comment_text_is_not_in_the_notification_or_the_email + secret = 'Please do not put this sentence in an email.' + @task.add_text_comment(@tutor, secret) + + notification = Notification.recent_first.first + body = delivered_body + + assert_not_empty body, 'guard: the body must be readable or this test proves nothing' + assert_not_includes notification.message, secret + assert_not_includes body, secret + end + + def test_the_message_names_the_commenter_and_the_task + @task.add_text_comment(@tutor, 'Named check.') + + message = Notification.recent_first.first.message + + assert_includes message, @tutor.name + assert_includes message, @task_definition.abbreviation + end + + def test_the_link_points_at_the_task_on_the_student_dashboard + @task.add_text_comment(@tutor, 'Link check.') + + assert_equal( + "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}", + Notification.recent_first.first.link + ) + end + + def test_the_event_specific_template_is_used_instead_of_the_generic_one + @task.add_text_comment(@tutor, 'Template check.') + + body = delivered_body + + # Wording that only exists in task_comment_created.*.erb. If the mailer ever + # falls back to single_notification.*.erb this fails. + assert_includes body, 'The comment is not included in this email' + end + + def test_a_notification_failure_does_not_stop_the_comment_being_posted + comment = nil + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification exploded' } do + comment = @task.add_text_comment(@tutor, 'This must still be saved.') + end + + assert_not_nil comment + assert comment.persisted? + assert_equal 'This must still be saved.', comment.comment + end + + def test_no_notification_and_no_error_when_there_is_no_recipient + comment = TaskComment.new(recipient: nil) + + assert_no_difference 'Notification.count' do + assert_nothing_raised { @task.notify_comment_recipient(comment) } + end + end +end From bf36e624e7dec003e94a704efd339125f894c958 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 2 Aug 2026 17:49:55 +1000 Subject: [PATCH 007/247] chore(mail): send development mail to the catcher, fix the path comment --- config/environments/development.rb | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/config/environments/development.rb b/config/environments/development.rb index 05d01df74c..f87cbef55d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -60,9 +60,28 @@ config.action_mailer.perform_caching = false - # Tell Action Mailer not to deliver emails to the real world. - # Write them to file instead (under doubtfire-api/tmp/mails) - config.action_mailer.delivery_method = :file + # Never deliver email to the real world in development. + # + # With docker (the normal case), DF_SMTP_ADDRESS points at the mailpit + # container and every email shows up in a web inbox at http://localhost:8025. + # Mailpit accepts everything and forwards nothing. + # + # Without it, mail is written to a file instead. Under docker that file lands + # on the host at doubtfire-deploy/data/tmp/mails/, NOT in this repository, + # because development/docker-compose.yml mounts ../data/tmp over /doubtfire/tmp + # and Rails.root in the container is /doubtfire. Looking for it under + # doubtfire-api/tmp/mails shows an empty folder and makes email look broken. + # + # See doubtfire-deploy/RUNNING-LOCALLY.md. + if ENV['DF_SMTP_ADDRESS'].present? + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV['DF_SMTP_ADDRESS'], + port: ENV.fetch('DF_SMTP_PORT', 1025).to_i + } + else + config.action_mailer.delivery_method = :file + end # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log From 6c023bd2b8d24a50fe7fb39f1f94a6ee10b0f167 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 2 Aug 2026 17:50:02 +1000 Subject: [PATCH 008/247] feat(notifications): add push subscription storage and api --- app/api/api_root.rb | 2 + app/api/entities/push_subscription_entity.rb | 12 ++ app/api/push_subscriptions_api.rb | 59 +++++++ app/models/push_subscription.rb | 13 ++ app/models/user.rb | 1 + ...0260802000003_create_push_subscriptions.rb | 28 ++++ db/schema.rb | 14 +- test/api/push_subscriptions_api_test.rb | 158 ++++++++++++++++++ 8 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 app/api/entities/push_subscription_entity.rb create mode 100644 app/api/push_subscriptions_api.rb create mode 100644 app/models/push_subscription.rb create mode 100644 db/migrate/20260802000003_create_push_subscriptions.rb create mode 100644 test/api/push_subscriptions_api_test.rb diff --git a/app/api/api_root.rb b/app/api/api_root.rb index 2b13296a7b..e6a8c07adf 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -113,6 +113,7 @@ class ApiRoot < Grape::API # Notifications feature mount NotificationsApi + mount PushSubscriptionsApi # # Add auth details to all end points @@ -167,6 +168,7 @@ class ApiRoot < Grape::API # Notifications feature AuthenticationHelpers.add_auth_to NotificationsApi + AuthenticationHelpers.add_auth_to PushSubscriptionsApi add_swagger_documentation \ base_path: nil, diff --git a/app/api/entities/push_subscription_entity.rb b/app/api/entities/push_subscription_entity.rb new file mode 100644 index 0000000000..32a865022c --- /dev/null +++ b/app/api/entities/push_subscription_entity.rb @@ -0,0 +1,12 @@ +module Entities + class PushSubscriptionEntity < Grape::Entity + expose :id + expose :endpoint + expose :created_at + expose :updated_at + + # p256dh and auth are deliberately not exposed. They are the browser's own + # encryption material, the client already holds them, and nothing in the UI + # needs them read back. + end +end diff --git a/app/api/push_subscriptions_api.rb b/app/api/push_subscriptions_api.rb new file mode 100644 index 0000000000..1b24f94081 --- /dev/null +++ b/app/api/push_subscriptions_api.rb @@ -0,0 +1,59 @@ +require 'grape' + +class PushSubscriptionsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get the push subscriptions belonging to the current user' + get '/push_subscriptions' do + present current_user.push_subscriptions.order(:id), with: Entities::PushSubscriptionEntity + end + + desc 'Register this browser to receive push notifications' + params do + requires :endpoint, type: String, desc: 'The push service URL, from PushSubscription.endpoint' + requires :p256dh, type: String, desc: 'The browser public key, from PushSubscription.getKey("p256dh")' + requires :auth, type: String, desc: 'The browser auth secret, from PushSubscription.getKey("auth")' + end + post '/push_subscriptions' do + subscription = current_user.push_subscriptions.find_by(endpoint: params[:endpoint]) + + # Not ours, or not stored yet. An endpoint identifies a browser rather than + # a person, so an endpoint held by another user means someone has signed in + # on a machine that account used. Move the registration across instead of + # failing on the unique index. + # + # This is the only lookup in this file that is not scoped to current_user, + # and it is safe. The push service delivers to that browser no matter which + # row owns it, and the payload is encrypted to the keys posted here. Taking + # over someone else's endpoint cannot read their notifications, it can only + # stop them arriving, and you need the endpoint URL to try it at all. + subscription ||= PushSubscription.find_by(endpoint: params[:endpoint]) || PushSubscription.new + + subscription.assign_attributes( + user: current_user, + endpoint: params[:endpoint], + p256dh: params[:p256dh], + auth: params[:auth] + ) + subscription.save! + + present subscription, with: Entities::PushSubscriptionEntity + end + + desc 'Stop this browser receiving push notifications' + params do + requires :endpoint, type: String, desc: 'The push service URL to remove' + end + delete '/push_subscriptions' do + subscription = current_user.push_subscriptions.find_by!(endpoint: params[:endpoint]) + subscription.destroy! + + status 200 + { success: true } + end +end diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000000..cc90bcdbd8 --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,13 @@ +# One browser registered to receive web push notifications. +# +# The endpoint is the URL the push service gave that browser. It identifies the +# browser, not the person, so it is unique across the whole table: if the same +# browser signs in as a different user the registration moves across instead of +# being duplicated. PushSubscriptionsApi does that move. +class PushSubscription < ApplicationRecord + belongs_to :user + + validates :endpoint, presence: true, uniqueness: true, length: { maximum: 500 } + validates :p256dh, presence: true, length: { maximum: 255 } + validates :auth, presence: true, length: { maximum: 255 } +end diff --git a/app/models/user.rb b/app/models/user.rb index 45e6f26462..fc3160178f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -166,6 +166,7 @@ def token_for_text?(a_token, token_type) # Notifications feature has_many :notifications, dependent: :destroy, inverse_of: :user + has_many :push_subscriptions, dependent: :destroy, inverse_of: :user # Model validations/constraints validates :first_name, presence: true diff --git a/db/migrate/20260802000003_create_push_subscriptions.rb b/db/migrate/20260802000003_create_push_subscriptions.rb new file mode 100644 index 0000000000..8d444927ec --- /dev/null +++ b/db/migrate/20260802000003_create_push_subscriptions.rb @@ -0,0 +1,28 @@ +class CreatePushSubscriptions < ActiveRecord::Migration[8.0] + def change + create_table :push_subscriptions do |t| + t.references :user, foreign_key: true, null: false + + # The push service URL the browser hands us. We send to it, and it + # identifies the browser rather than the person, so it is unique across + # the whole table and not just per user. + # + # 500 rather than the default 255 because Firefox and Safari endpoints + # run close to 260 characters. A varchar(255) would reject them under + # strict mode and silently truncate them otherwise, and a truncated + # endpoint is a push that quietly goes nowhere. utf8mb4 makes this a + # 2000 byte index key, inside InnoDB's 3072 byte limit. + t.string :endpoint, null: false, limit: 500 + + # The browser's public key and auth secret. The payload is encrypted to + # these, so without them a push cannot be read by the browser that asked + # for it. + t.string :p256dh, null: false + t.string :auth, null: false + + t.timestamps + end + + add_index :push_subscriptions, :endpoint, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 4df9383bd3..65c90c33b5 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_02_000002) do +ActiveRecord::Schema[8.0].define(version: 2026_08_02_000003) 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 @@ -489,6 +489,17 @@ t.index ["user_id"], name: "index_projects_on_user_id" end + create_table "push_subscriptions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "endpoint", limit: 500, null: false + t.string "p256dh", null: false + t.string "auth", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true + t.index ["user_id"], name: "index_push_subscriptions_on_user_id" + end + create_table "roles", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name" t.text "description" @@ -1006,6 +1017,7 @@ 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 "notifications", "users" + add_foreign_key "push_subscriptions", "users" add_foreign_key "user_oauth_states", "users" add_foreign_key "user_oauth_tokens", "users" end diff --git a/test/api/push_subscriptions_api_test.rb b/test/api/push_subscriptions_api_test.rb new file mode 100644 index 0000000000..596bb37af5 --- /dev/null +++ b/test/api/push_subscriptions_api_test.rb @@ -0,0 +1,158 @@ +require 'test_helper' + +# MN-F01: storing a browser's push registration. +class PushSubscriptionsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + @user = FactoryBot.create(:user, :student) + @other = FactoryBot.create(:user, :student) + end + + # A plausible subscription. Real endpoints are long, which is the whole reason + # the column is not a default varchar(255). + def subscription_params(endpoint: 'https://fcm.googleapis.com/fcm/send/abc123') + { + endpoint: endpoint, + p256dh: 'BExampleBrowserPublicKeyValue', + auth: 'ExampleAuthSecret' + } + end + + def test_a_user_can_register_a_browser + add_auth_header_for(user: @user) + + assert_difference 'PushSubscription.count', 1 do + post '/api/push_subscriptions', subscription_params + end + + assert_equal 201, last_response.status + + subscription = PushSubscription.last + + assert_equal @user, subscription.user + assert_equal 'https://fcm.googleapis.com/fcm/send/abc123', subscription.endpoint + end + + def test_the_response_does_not_leak_the_browser_keys + add_auth_header_for(user: @user) + post '/api/push_subscriptions', subscription_params + + json = JSON.parse(last_response.body) + + assert_equal 'https://fcm.googleapis.com/fcm/send/abc123', json['endpoint'] + assert_not json.key?('p256dh'), 'the browser public key must not be sent back' + assert_not json.key?('auth'), 'the browser auth secret must not be sent back' + end + + def test_registering_the_same_browser_twice_updates_instead_of_duplicating + add_auth_header_for(user: @user) + post '/api/push_subscriptions', subscription_params + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', subscription_params.merge(p256dh: 'BRotatedPublicKey') + end + + assert_equal 'BRotatedPublicKey', PushSubscription.last.p256dh + end + + # Shared machine. The endpoint belongs to the browser, so the registration has + # to move to whoever signed in last rather than blowing up on the unique index. + def test_registering_a_browser_another_user_had_moves_it_across + subscription = @other.push_subscriptions.create!(subscription_params) + + add_auth_header_for(user: @user) + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', subscription_params + end + + assert_equal @user, subscription.reload.user + assert_empty @other.push_subscriptions.reload + end + + def test_a_user_only_sees_their_own_registrations + @user.push_subscriptions.create!(subscription_params(endpoint: 'https://push.example.com/mine')) + @other.push_subscriptions.create!(subscription_params(endpoint: 'https://push.example.com/theirs')) + + add_auth_header_for(user: @user) + get '/api/push_subscriptions' + + assert_equal 200, last_response.status + + json = JSON.parse(last_response.body) + + assert_equal 1, json.length + assert_equal 'https://push.example.com/mine', json.first['endpoint'] + end + + def test_a_user_can_remove_their_own_registration + @user.push_subscriptions.create!(subscription_params) + + add_auth_header_for(user: @user) + + assert_difference 'PushSubscription.count', -1 do + delete '/api/push_subscriptions', endpoint: subscription_params[:endpoint] + end + + assert_equal 200, last_response.status + end + + def test_a_user_cannot_remove_someone_elses_registration + @other.push_subscriptions.create!(subscription_params) + + add_auth_header_for(user: @user) + + assert_no_difference 'PushSubscription.count' do + delete '/api/push_subscriptions', endpoint: subscription_params[:endpoint] + end + + assert_equal 404, last_response.status + end + + def test_an_unauthenticated_request_is_rejected + clear_auth_header + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', subscription_params + end + + assert_equal 419, last_response.status + end + + def test_a_registration_missing_the_browser_keys_is_rejected + add_auth_header_for(user: @user) + + assert_no_difference 'PushSubscription.count' do + post '/api/push_subscriptions', endpoint: 'https://push.example.com/incomplete' + end + + assert_equal 400, last_response.status + end + + # Firefox and Safari endpoints run past the 255 characters a default string + # column would give us. If this ever fails the migration has regressed. + def test_a_long_endpoint_is_stored_whole + long_endpoint = "https://updates.push.services.mozilla.com/wpush/v2/#{'a' * 300}" + + add_auth_header_for(user: @user) + post '/api/push_subscriptions', subscription_params(endpoint: long_endpoint) + + assert_equal 201, last_response.status + assert_equal long_endpoint, PushSubscription.last.endpoint + end + + def test_deleting_the_user_deletes_their_registrations + @user.push_subscriptions.create!(subscription_params) + + assert_difference 'PushSubscription.count', -1 do + @user.destroy! + end + end +end From f8c21dccb765a137000af039cc7932cc3bcc27b0 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 2 Aug 2026 17:50:11 +1000 Subject: [PATCH 009/247] feat(notifications): implement web push delivery --- Gemfile | 11 ++ Gemfile.lock | 7 + app/services/push_notification_service.rb | 90 ++++++++- docs/notifications/push-setup.md | 154 +++++++++++++++ .../push_notification_service_test.rb | 180 ++++++++++++++++++ 5 files changed, 432 insertions(+), 10 deletions(-) create mode 100644 docs/notifications/push-setup.md create mode 100644 test/services/push_notification_service_test.rb diff --git a/Gemfile b/Gemfile index b367b82225..1e44c22581 100644 --- a/Gemfile +++ b/Gemfile @@ -124,3 +124,14 @@ gem "sys-filesystem" gem "sentry-rails" gem "sentry-ruby" + +# Web push notifications. Signs and encrypts payloads for the browser push +# services (VAPID). See docs/notifications/push-setup.md. +# +# Pinned exactly, on purpose. web-push 3.0.1 and later require jwt ~> 3.0, and +# taking that drags jwt from 2.10 to a new major version and forces oauth2 from +# 2.0.9 to 2.0.25 with it, because the older oauth2 caps jwt below 3. That would +# make this change touch LTI (lib/../lti_helper.rb calls JWT.decode) and the D2L +# OAuth integration, neither of which has anything to do with push. 3.0.0 works +# against the jwt already in the lockfile and keeps the diff to one gem. +gem 'web-push', '3.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index 9df7ab4c0a..36630f43ba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,6 +199,7 @@ GEM hashery (2.1.2) hashie (5.0.0) hirb (0.7.3) + hkdf (1.0.0) http-accept (1.7.0) http-cookie (1.0.8) domain_name (~> 0.5) @@ -292,6 +293,7 @@ GEM snaky_hash (~> 2.0) version_gem (~> 1.1) observer (0.1.2) + openssl (3.3.3) orm_adapter (0.5.0) ostruct (0.6.1) parallel (1.26.3) @@ -551,6 +553,10 @@ GEM version_gem (1.1.6) warden (1.2.9) rack (>= 2.0.9) + web-push (3.0.0) + hkdf (~> 1.0) + jwt (~> 2.0) + openssl (~> 3.0) webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -633,6 +639,7 @@ DEPENDENCIES sprockets-rails sys-filesystem tca_client + web-push (= 3.0.0) webmock RUBY VERSION diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb index 3e6657715a..365444ef08 100644 --- a/app/services/push_notification_service.rb +++ b/app/services/push_notification_service.rb @@ -1,24 +1,94 @@ # Web Push delivery channel. # -# Stubbed until VAPID keys are provisioned in the deploy environment (Stage 4). -# It is safe to call now: deliver is a no-op until keys are configured, so the -# notification fan-out works today without push wired up. +# NotificationService calls this for every notification it creates, straight +# after the email. Two properties make that safe: # -# Stage 4 will: -# 1. add a push_subscriptions table (user_id, endpoint, p256dh, auth) -# 2. read DOUBTFIRE_VAPID_PUBLIC_KEY / DOUBTFIRE_VAPID_PRIVATE_KEY -# 3. iterate notification.user.push_subscriptions and send the payload via -# the web-push gem (WebPush.payload_send) +# * without VAPID keys it is a no-op, so the app behaves exactly as it did +# before push existed for anyone who has not configured them +# * one browser failing never stops the others and never reaches the caller, +# so a push problem cannot block an in-app notification or an email +# +# Because the fan-out already calls this, every event that sends an email now +# sends a push too, with no per-event work. +# +# Key generation and setup: docs/notifications/push-setup.md. class PushNotificationService + # Push services reject a payload much over 4KB once encrypted. Nothing here + # comes close, but the message is user-facing text assembled from names and + # task titles, so it is trimmed rather than trusted. + MAX_BODY_LENGTH = 400 + def self.deliver(notification) return unless configured? - # TODO(Stage 4): send to notification.user.push_subscriptions via web-push. - Rails.logger.debug "Push channel not yet wired for notification #{notification.id}" + subscriptions = notification.user.push_subscriptions.to_a + return if subscriptions.empty? + + payload = payload_for(notification) + + subscriptions.each { |subscription| deliver_to(subscription, payload) } + end + + # The shape Angular's own ngsw-worker.js understands. It looks for a top level + # "notification" key and displays the notification itself, which is why none of + # this needs a hand written service worker. Use any other shape and somebody + # has to write one. + # + # data.link is what MN-C03 reads to decide where to send the user on click. + def self.payload_for(notification) + { + notification: { + title: Doubtfire::Application.config.institution[:product_name], + body: notification.message.to_s.truncate(MAX_BODY_LENGTH), + data: { + notification_id: notification.id, + link: notification.link + } + } + }.to_json + end + + def self.deliver_to(subscription, payload) + WebPush.payload_send( + message: payload, + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + vapid: vapid_details + ) + rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription => e + # 410 or 404. The browser has thrown this registration away, or it was never + # valid. Nothing will ever reach it again, so drop the row rather than retry + # it on every notification from here to the end of time. + Rails.logger.info "Removing dead push subscription #{subscription.id}: #{e.class}" + subscription.destroy + rescue StandardError => e + # Rate limits, the push service being down, a rejected payload. Somebody + # else's outage, and not a reason to fail the notification. Log and move on + # to the next browser. + Rails.logger.error "Failed to push to subscription #{subscription.id}: #{e.class}: #{e.message}" + end + + def self.vapid_details + { + subject: vapid_subject, + public_key: ENV.fetch('DOUBTFIRE_VAPID_PUBLIC_KEY'), + private_key: ENV.fetch('DOUBTFIRE_VAPID_PRIVATE_KEY') + } + end + + # Push services want a way to contact whoever is sending, as a mailto: or a + # URL. They reject the request outright if it is missing. + def self.vapid_subject + ENV['DOUBTFIRE_VAPID_SUBJECT'].presence || + Doubtfire::Application.config.institution[:host].presence || + 'mailto:noreply@doubtfire.local' end # True once VAPID keys are configured. Keeps the fan-out safe to call today. def self.configured? ENV['DOUBTFIRE_VAPID_PUBLIC_KEY'].present? && ENV['DOUBTFIRE_VAPID_PRIVATE_KEY'].present? end + + private_class_method :deliver_to, :vapid_details, :vapid_subject end diff --git a/docs/notifications/push-setup.md b/docs/notifications/push-setup.md new file mode 100644 index 0000000000..408b54136a --- /dev/null +++ b/docs/notifications/push-setup.md @@ -0,0 +1,154 @@ +# Web push setup + +How the push channel works, how to turn it on, and how to check it is working. + +## What push needs + +Three things, and push is a no-op until all three are true: + +1. **VAPID keys on the api.** Without them `PushNotificationService.configured?` + is false and `deliver` returns immediately. The app behaves exactly as it did + before push existed. +2. **A row in `push_subscriptions`.** A browser has to register itself first. + MN-F01 added the table and the API. +3. **A service worker in the browser.** MN-F03 turns it on for development. + Without it, the browser has nothing to receive a push with. Setup, + caching side effects and how to clear a stuck worker are in the web repo: + `doubtfire-web/docs/service-worker.md`. + +Miss any one and nothing arrives, with no error anywhere. Check them in order. + +## The keys + +VAPID is how a push service knows the push came from us and not from anyone else +who happens to know a browser's endpoint URL. It is one key pair for the whole +server, not one per user. + +Generate a pair: + + docker exec doubtfire-api bundle exec ruby -e \ + "require 'web_push'; k = WebPush.generate_key; puts k.public_key; puts k.private_key" + +Then set three environment variables on the api: + +| Variable | What it is | +|---|---| +| `DOUBTFIRE_VAPID_PUBLIC_KEY` | Public half. The browser needs this to subscribe. | +| `DOUBTFIRE_VAPID_PRIVATE_KEY` | **Secret.** Signs every push. Never commit a real one. | +| `DOUBTFIRE_VAPID_SUBJECT` | A `mailto:` or URL the push service can contact. Optional; falls back to the institution host. | + +`development/docker-compose.yml` in the deploy repo already carries a throwaway +pair so the local stack works out of the box, on the same footing as +`DF_SECRET_KEY_BASE`. That pair is development only. **A production deployment +sets its own through real secrets, and if the private key ever leaks, generate a +new pair — every existing subscription becomes useless and users have to +re-subscribe.** + +Changing the keys does not migrate anything. The `push_subscriptions` rows stay, +but pushes signed with the new key are rejected for browsers that subscribed +under the old one, and those rows get cleaned up as 403s arrive. + +## How a notification becomes a push + +`NotificationService.notify` already calls `PushNotificationService.deliver`, so +**every event that sends an email sends a push, with no per-event work**. Nothing +in an event ticket has to know push exists. + +`deliver` loops over `notification.user.push_subscriptions` and sends this +payload to each: + +```json +{ + "notification": { + "title": "OnTrack", + "body": "Andrew Cain commented on 1.1P in COS10001.", + "data": { "notification_id": 12, "link": "/projects/2/dashboard/1.1P" } + } +} +``` + +The top level `notification` key matters. Angular's own `ngsw-worker.js` looks +for exactly that and displays the notification itself. **Change the shape and +somebody has to write a service worker by hand.** + +`data.link` is what MN-C03 reads to decide where to send the user on click. + +## Failure handling + +- **404 or 410** means the browser threw the registration away. The row is + deleted, because nothing will ever reach it again. +- **Anything else** (429 rate limit, the push service being down, a rejected + payload) is logged and skipped. The subscription is kept, because those are + temporary and deleting on them would silently unsubscribe people the first time + a push service had a bad day. +- Nothing propagates to the caller. A push failure must never block the in-app + notification or the email. + +## Checking it works + +**Are the keys loaded?** + + docker exec doubtfire-api bundle exec rails runner \ + 'puts PushNotificationService.configured?' + +`false` means the api container was started before the variables were added. +`restart` does not pick up new environment variables. Recreate it: + + docker compose -f docker-compose.yml -f docker-compose.local-paths.yml up -d doubtfire-api + +**Is a browser registered?** + + docker exec doubtfire-api bundle exec rails runner \ + 'puts PushSubscription.all.map { |s| "#{s.user.username} #{s.endpoint[0, 60]}" }' + +Empty means nothing has subscribed yet. That is the usual reason a push does not +arrive, and it looks identical to push being broken. + +**Subscribe this browser by hand.** Until MN-C01 adds the opt-in button, paste +this into the dev tools console on a page where you are signed in. It needs +MN-F03 done first, or `navigator.serviceWorker.ready` never resolves. + +```js +const VAPID = '' +const b64 = s => Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)) + +const reg = await navigator.serviceWorker.ready +const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: b64(VAPID) +}) +const j = sub.toJSON() + +await fetch('/api/push_subscriptions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Username': localStorage.getItem('username'), + 'Auth-Token': localStorage.getItem('authToken') + }, + body: JSON.stringify({ endpoint: j.endpoint, p256dh: j.keys.p256dh, auth: j.keys.auth }) +}) +``` + +Then raise a notification (post a task comment) and a desktop notification +should appear. + +**Nothing appeared?** Check in this order, because each step is invisible when it +fails: + +1. Browser notification permission. `Notification.permission` must be `granted`. + macOS also has to allow notifications from the browser, in System Settings. +2. `PushNotificationService.configured?` is true. +3. A `push_subscriptions` row exists for the user the notification went to. It is + easy to subscribe as one account and then trigger a notification for another. +4. `docker logs doubtfire-api | grep -i "push"`. Delivery failures are logged and + swallowed, so this is the only place they show up. + +## Why the gem is pinned + +`Gemfile` pins `web-push` to exactly `3.0.0`. Versions from 3.0.1 require +`jwt ~> 3.0`, and taking that forces `jwt` to a new major version and drags +`oauth2` from 2.0.9 to 2.0.25 with it, because the older `oauth2` caps `jwt` +below 3. That would put LTI (`app/helpers/lti_helper.rb` calls `JWT.decode`) and +the D2L OAuth integration inside the blast radius of a push change. Unpinning is +a separate piece of work with its own testing. diff --git a/test/services/push_notification_service_test.rb b/test/services/push_notification_service_test.rb new file mode 100644 index 0000000000..a5ae633b19 --- /dev/null +++ b/test/services/push_notification_service_test.rb @@ -0,0 +1,180 @@ +require 'test_helper' + +# MN-F02: the push channel actually sends. +# +# These tests go through the real web-push gem and stub the HTTP call to the +# push service, rather than stubbing WebPush itself. That is deliberate: the +# part most likely to be wired up wrong is the gem call and the payload, and a +# stub of WebPush.payload_send would prove neither. +class PushNotificationServiceTest < ActiveSupport::TestCase + # A throwaway VAPID pair and a throwaway browser key pair, generated for these + # tests. The p256dh has to be a real prime256v1 public key or the gem cannot + # encrypt the payload, so it cannot be a made up string. + VAPID_PUBLIC = 'BOs-KbIoHK7gUIX3i2_uEuDoouj-GKxB-mY9CRmLNmd4Wn-SSl254E1g6jR1ukL3e37p8uCpaMjOvfAB0BwzvSI='.freeze + VAPID_PRIVATE = '_NFIWSUTdCdLJJFh87pf4ekQLmNYqsweZ4288NpVZaY='.freeze + BROWSER_P256DH = 'BJy8RpjMkwOPDIIXSu-FTe7OosAwY9G86_evhrn0jJbPnoxXjBYpn7aPHEIaRh3GxCzFvwYXjKWvtu3FEMaBQMY='.freeze + BROWSER_AUTH = 'CUkmaYqq8eINt1HTnFY65w=='.freeze + + ENDPOINT = 'https://fcm.googleapis.com/fcm/send/test-browser'.freeze + + setup do + @user = FactoryBot.create(:user, :student) + @notification = Notification.create!( + user: @user, + notification_type: 'feedback', + event: 'task_comment_created', + message: 'Andrew Cain commented on 1.1P in COS10001.', + link: '/projects/2/dashboard/1.1P' + ) + end + + # Set the keys for the block and put the environment back exactly as it was. + # + # Restoring rather than deleting matters: the development container now has + # real values in its environment, so a test that deleted them would leave the + # process different from how it found it, and a test that assumed they were + # absent to begin with would pass in CI and fail on a developer's machine. + def with_env(values) + previous = values.keys.index_with { |key| ENV.fetch(key, nil) } + values.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + yield + ensure + previous.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + + def with_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => VAPID_PUBLIC, 'DOUBTFIRE_VAPID_PRIVATE_KEY' => VAPID_PRIVATE }, &) + end + + def without_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => nil, 'DOUBTFIRE_VAPID_PRIVATE_KEY' => nil }, &) + end + + def create_subscription(endpoint: ENDPOINT) + @user.push_subscriptions.create!( + endpoint: endpoint, + p256dh: BROWSER_P256DH, + auth: BROWSER_AUTH + ) + end + + def test_nothing_is_sent_when_the_vapid_keys_are_missing + create_subscription + + # No WebMock stub is registered, so any outbound request would raise. + without_keys do + assert_not PushNotificationService.configured? + assert_nothing_raised { PushNotificationService.deliver(@notification) } + end + end + + def test_a_notification_is_pushed_to_the_subscribed_browser + create_subscription + request = stub_request(:post, ENDPOINT).to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested request + end + + def test_every_subscribed_browser_is_pushed_to + create_subscription(endpoint: "#{ENDPOINT}-one") + create_subscription(endpoint: "#{ENDPOINT}-two") + + first = stub_request(:post, "#{ENDPOINT}-one").to_return(status: 201) + second = stub_request(:post, "#{ENDPOINT}-two").to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested first + assert_requested second + end + + def test_nothing_is_sent_when_the_user_has_no_browsers_registered + with_keys { assert_nothing_raised { PushNotificationService.deliver(@notification) } } + end + + def test_a_gone_subscription_is_deleted + create_subscription + stub_request(:post, ENDPOINT).to_return(status: 410) + + assert_difference 'PushSubscription.count', -1 do + with_keys { PushNotificationService.deliver(@notification) } + end + end + + def test_a_not_found_subscription_is_deleted + create_subscription + stub_request(:post, ENDPOINT).to_return(status: 404) + + assert_difference 'PushSubscription.count', -1 do + with_keys { PushNotificationService.deliver(@notification) } + end + end + + # A rate limit or an outage is temporary. Deleting on those would silently + # unsubscribe people the first time a push service had a bad day. + def test_a_temporary_push_service_failure_keeps_the_subscription + create_subscription + stub_request(:post, ENDPOINT).to_return(status: 429) + + assert_no_difference 'PushSubscription.count' do + with_keys { assert_nothing_raised { PushNotificationService.deliver(@notification) } } + end + end + + def test_one_dead_browser_does_not_stop_the_others + create_subscription(endpoint: "#{ENDPOINT}-dead") + create_subscription(endpoint: "#{ENDPOINT}-alive") + + stub_request(:post, "#{ENDPOINT}-dead").to_return(status: 410) + alive = stub_request(:post, "#{ENDPOINT}-alive").to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested alive + assert_equal ["#{ENDPOINT}-alive"], @user.push_subscriptions.reload.map(&:endpoint) + end + + # Angular's ngsw-worker.js only displays a push if the payload has a top level + # "notification" key. Anything else needs a hand written service worker. + def test_the_payload_has_the_shape_angulars_service_worker_expects + payload = JSON.parse(PushNotificationService.payload_for(@notification)) + + assert payload.key?('notification'), 'ngsw-worker.js will ignore a payload without this key' + + body = payload['notification'] + + assert_equal 'Andrew Cain commented on 1.1P in COS10001.', body['body'] + assert_equal '/projects/2/dashboard/1.1P', body.dig('data', 'link') + assert_equal @notification.id, body.dig('data', 'notification_id') + assert_not_nil body['title'] + end + + def test_a_long_message_is_trimmed_rather_than_rejected_by_the_push_service + @notification.update!(message: 'a' * 500) + + body = JSON.parse(PushNotificationService.payload_for(@notification))['notification']['body'] + + assert_operator body.length, :<=, PushNotificationService::MAX_BODY_LENGTH + end + + # The whole point of MN-F02: the fan-out already calls this service, so an + # event that sends an email now sends a push with no extra work. + def test_raising_a_notification_through_the_hub_sends_a_push + create_subscription + request = stub_request(:post, ENDPOINT).to_return(status: 201) + + with_keys do + NotificationService.notify( + user: @user, + type: 'feedback', + event: 'task_comment_created', + message: 'Raised through the hub.', + link: '/projects/2/dashboard/1.1P' + ) + end + + assert_requested request + end +end From fe959ffa1885ca7c09a08f916460a9a86ede1131 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 2 Aug 2026 18:07:33 +1000 Subject: [PATCH 010/247] feat(notifications): publish the vapid public key in settings --- app/api/settings_api.rb | 12 +++++- test/api/settings_push_test.rb | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 test/api/settings_push_test.rb diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb index 49968392ca..af9f60e95f 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -13,7 +13,17 @@ class SettingsApi < Grape::API logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url], overseerEnabled: Doubtfire::Application.config.overseer_enabled, tiiEnabled: TurnItIn.enabled?, - d2lEnabled: D2lIntegration.enabled? + d2lEnabled: D2lIntegration.enabled?, + + # Web push. The VAPID *public* key is not a secret — the browser has to + # send it to the push service to subscribe at all. Serving it here means it + # is configured in one place instead of being copied into the front end and + # going stale the first time the keys are rotated. + # + # Blank when push is not configured, which is how the client knows not to + # offer the opt-in. + pushEnabled: PushNotificationService.configured?, + vapidPublicKey: ENV.fetch('DOUBTFIRE_VAPID_PUBLIC_KEY', nil).presence } present response, with: Grape::Presenters::Presenter diff --git a/test/api/settings_push_test.rb b/test/api/settings_push_test.rb new file mode 100644 index 0000000000..0171414694 --- /dev/null +++ b/test/api/settings_push_test.rb @@ -0,0 +1,70 @@ +require 'test_helper' + +# MN-C01: the front end reads the VAPID public key from /api/settings so it is +# configured in one place instead of being copied into the web repo and going +# stale the first time the keys are rotated. +# +# Separate from settings_test.rb on purpose. That file predates rubocop's style +# rules and already carries 17 offenses; adding to it would either add more or +# mean reformatting a file this ticket has no business touching. +class SettingsPushTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + # Restores whatever was there rather than deleting. The development container + # really does have these set, so a test that assumed they were absent would + # pass in CI and fail on a developer's machine. + def with_env(values) + previous = values.keys.index_with { |key| ENV.fetch(key, nil) } + values.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + yield + ensure + previous.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + end + + def with_vapid_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => 'BTestPublicKey', 'DOUBTFIRE_VAPID_PRIVATE_KEY' => 'BTestPrivateKey' }, &) + end + + def without_vapid_keys(&) + with_env({ 'DOUBTFIRE_VAPID_PUBLIC_KEY' => nil, 'DOUBTFIRE_VAPID_PRIVATE_KEY' => nil }, &) + end + + def test_the_public_key_is_published_when_push_is_configured + with_vapid_keys do + get '/api/settings' + + assert_equal 200, last_response.status + assert_equal true, last_response_body['pushEnabled'] + assert_equal 'BTestPublicKey', last_response_body['vapidPublicKey'] + end + end + + # Without keys the client must not offer the opt-in. Subscribing would fail in + # the browser with nothing on screen to explain why. + def test_push_is_reported_unavailable_without_keys + without_vapid_keys do + get '/api/settings' + + assert_equal 200, last_response.status + assert_equal false, last_response_body['pushEnabled'] + assert_nil last_response_body['vapidPublicKey'] + end + end + + # The public key is safe to publish. The private key is not, and this endpoint + # needs no authentication at all. + def test_the_private_key_is_never_published + with_vapid_keys do + get '/api/settings' + + assert_not_includes last_response.body, 'BTestPrivateKey' + assert_not_includes last_response_body.keys, 'vapidPrivateKey' + end + end +end From ccb178d59cf810803164bafb76bf664b33986630 Mon Sep 17 00:00:00 2001 From: Leeon Gourav Rangey Date: Mon, 3 Aug 2026 01:46:33 +1000 Subject: [PATCH 011/247] docs: add safe starter email templates --- .../safe_starter_email_templates.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/email_notifications/safe_starter_email_templates.md diff --git a/docs/email_notifications/safe_starter_email_templates.md b/docs/email_notifications/safe_starter_email_templates.md new file mode 100644 index 0000000000..2cad342331 --- /dev/null +++ b/docs/email_notifications/safe_starter_email_templates.md @@ -0,0 +1,173 @@ + +# Safe Starter Email Templates + +## Purpose + +These starter templates provide privacy-aware wording for OnTrack email +notifications. They avoid exposing unnecessary assessment information in email +subject lines and bodies. + +Recipients should sign in to OnTrack to view task names, unit information, +feedback, marks, comments, submissions, dates, and other assessment details. + +## Privacy Guidelines + +- Keep subject lines generic. +- Do not include student names in subject lines. +- Do not include unit names or task names in subject lines. +- Do not include marks, grades, feedback text, tutor comments, or submission details. +- Direct users to sign in to OnTrack to view protected information. +- Do not include personal information or authentication tokens in URLs. +- Respect the user's notification preferences. +- Use configured OnTrack names and URLs instead of hard-coded deployment details. + +## Suggested Placeholders + +- `{{product_name}}`: the configured system name, such as OnTrack. +- `{{sign_in_url}}`: a secure link to the OnTrack sign-in page. +- `{{notification_settings_url}}`: the user's notification settings page. + +--- + +## 1. Due Soon + +### Subject + +```text +{{product_name}} reminder: a due date is approaching +``` + +### Body + +```text +Hello, + +A task in {{product_name}} is due soon. + +Sign in to review the task and confirm the due date: +{{sign_in_url}} + +No assessment details are included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include the task name, unit name, due date, submission +status, or other assessment details. + +--- + +## 2. Feedback Available + +### Subject + +```text +{{product_name}} notification: feedback is available +``` + +### Body + +```text +Hello, + +New feedback is available in {{product_name}}. + +Sign in to view it securely: +{{sign_in_url}} + +No feedback content is included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include feedback text, task names, unit names, staff +comments, marks, or assessment results. + +--- + +## 3. Task Marked + +### Subject + +```text +{{product_name}} notification: a task has been marked +``` + +### Body + +```text +Hello, + +A task has been marked in {{product_name}}. + +Sign in to review the outcome and any next steps: +{{sign_in_url}} + +No mark or result is included in this email. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include the mark, grade, result, task name, unit name, +or feedback. + +--- + +## 4. Date Changed + +### Subject + +```text +{{product_name}} notification: a task date has changed +``` + +### Body + +```text +Hello, + +A date associated with a task has changed in {{product_name}}. + +Sign in to confirm the current date: +{{sign_in_url}} + +Use the date displayed in {{product_name}} as the current source of truth. + +You can manage your notification preferences here: +{{notification_settings_url}} +``` + +### Privacy Note + +This email does not include the task name, unit name, previous date, or new date. + +--- + +## Key Finding + +The API repository already contains notification mailer functionality and +notification-related email views. Future implementation should investigate +reusing the existing mailer structure instead of creating a separate email +delivery system. + +## Recommended Next Step + +Before production implementation, the Email Notifications team should confirm: + +1. The trigger for each notification. +2. The user roles that receive each notification. +3. The secure destination URL for each email. +4. Whether exact dates may be included in email bodies. +5. Whether emails are sent immediately or through a background job. +6. How notification preferences and opt-out behaviour are applied. + +Production mailer code, event triggers, database changes, and frontend notification settings are outside the scope of this starter documentation task. \ No newline at end of file From 61f7e93f0661da2220c083b2af32f17889ce6bb1 Mon Sep 17 00:00:00 2001 From: Maple 'Ryan' Fox Date: Mon, 3 Aug 2026 12:27:44 +1000 Subject: [PATCH 012/247] Revise pull request template for clarity and completeness Updated the pull request template to include sections for Jira ticket, summary, testing, and security impact. --- docs/pull_request_template.md | 54 +++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md index b70119000e..92d5dc9709 100644 --- a/docs/pull_request_template.md +++ b/docs/pull_request_template.md @@ -1,35 +1,41 @@ -# Description +## Jira ticket -Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. +Ticket number or link: -Fixes # (issue) +## Summary -## Type of change +Briefly explain what you changed and why. -Please delete options that are not relevant. +## Target branch -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] This change requires a documentation update +Which shared branch should this be merged into? -# How Has This Been Tested? +Example: `feature/email-notifications` -Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration +## Testing -- [ ] Test A -- [ ] Test B +Explain how you tested the change. -# Checklist: +Include any useful commands, screenshots, logs, or test results. -- [ ] My code follows the style guidelines of this project -- [ ] I have performed a self-review of my own code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation if appropriate -- [ ] My changes generate no new warnings -- [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] I have created or extended unit tests to address my new additions -- [ ] New and existing unit tests pass locally with my changes -- [ ] Any dependent changes have been merged and published in downstream modules +## Security and privacy -If you have any questions, please contact @macite or @jakerenzella. +Does this change affect authentication, permissions, notifications, student data, +secrets, personal information, or privacy? + +If there is no known impact, write: `No known security or privacy impact.` + +## Evidence + +Add any screenshots, test output, diagrams, or other evidence that will help the reviewer. + +## Checklist + +- [ ] I selected the correct base branch. +- [ ] My changes match the assigned Jira ticket. +- [ ] I kept the change within the agreed scope. +- [ ] I tested my changes. +- [ ] I did not include passwords, tokens, API keys, secrets, or real student data. +- [ ] I updated relevant documentation, or no documentation change was needed. +- [ ] I reviewed my own changes before requesting review. +- [ ] This pull request is ready for review. From 7d507cd72f12d0280363fe295f71ec764ab9e86e Mon Sep 17 00:00:00 2001 From: Ronit Khokhar Date: Tue, 4 Aug 2026 00:37:52 +1000 Subject: [PATCH 013/247] test: add first-pass email notification test cases --- docs/email-notification-first-pass-tests.md | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/email-notification-first-pass-tests.md diff --git a/docs/email-notification-first-pass-tests.md b/docs/email-notification-first-pass-tests.md new file mode 100644 index 0000000000..92de9c4055 --- /dev/null +++ b/docs/email-notification-first-pass-tests.md @@ -0,0 +1,29 @@ +# Email Notification – First Pass Test Cases + +## Purpose + +The purpose of this document is to define an initial set of test cases for user email notification preferences and correct email delivery before implementation begins. + +This task documents expected behaviour only. No production code has been modified. + +## Test Cases + +| Test ID | Scenario | Preconditions | Test Action | Expected Result | +|---|---|---|---|---| +| EN-01 | Notifications enabled | The user has enabled email notifications and has a valid email address | Trigger a valid notification event | Exactly one email is delivered to the intended recipient | +| EN-02 | Notifications disabled | The user has disabled email notifications | Trigger the same notification event | No email is created, queued, or delivered | +| EN-03 | Wrong recipient | The event belongs to User A, while User B also exists in the system | Trigger the notification for User A | Only User A receives the email; User B receives nothing | +| EN-04 | Duplicate event | The same notification event is processed twice | Process the duplicate event | Only one email is delivered | +| EN-05 | Changed preference | The user changes the preference from enabled to disabled before the event | Trigger a notification after the preference change | The updated preference is respected and no email is delivered | + +## Key Finding + +Correct email delivery depends on validating both the user's latest notification preference and the intended recipient before sending the email. + +## Recommended Next Step + +Confirm how duplicate notification events will be identified and determine where automated tests for these scenarios should be implemented when the feature is developed. + +## Current Blocker + +The email notification feature is not yet fully implemented, so these test cases define expected behaviour only and cannot yet be executed as automated tests. \ No newline at end of file From 22671345d25a88a4ced28e5589368b326ff9c9b1 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Thu, 6 Aug 2026 03:22:00 +1000 Subject: [PATCH 014/247] docs(notifications): add per-event documentation template --- docs/notifications/events/README.md | 52 +++++++++++ docs/notifications/events/_template.md | 119 +++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 docs/notifications/events/README.md create mode 100644 docs/notifications/events/_template.md diff --git a/docs/notifications/events/README.md b/docs/notifications/events/README.md new file mode 100644 index 0000000000..adf511dc53 --- /dev/null +++ b/docs/notifications/events/README.md @@ -0,0 +1,52 @@ +# Notification events + +Every notification event gets its own file in this folder. One event, one file, +named after the event. + + docs/notifications/events/task_comment_created.md + docs/notifications/events/task_status_changed.md + docs/notifications/events/extension_granted.md + +The file name is the string passed as `event:` to `NotificationService.notify`. +If the code says `event: 'task_comment_created'` then the file is +`task_comment_created.md`. No other naming, no grouping by category, no folders +inside this one. + +## Why one file each + +Eight event tickets run at the same time. If they all documented their event in +one shared file, every one of them would edit the same lines and every one would +conflict with the other seven. The first to merge wins and the other seven stop +to fix a merge by hand, for a docs change that had nothing to do with anyone +else's work. + +Adding a file conflicts with nothing. Two people can add +`task_status_changed.md` and `extension_granted.md` on the same afternoon and +neither branch touches the other. + +This is the same reason the mailer looks its template up by event name instead +of holding a lookup table. A new event adds +`app/views/notifications_mailer/.html.erb` and `.text.erb` and edits no +existing file. The docs follow the code. + +## Adding one + +1. Copy `_template.md` to `.md`. +2. Fill in the eight fields. Read the code and copy the real values out of it, + do not write down what you think it does. +3. Delete the field guidance and the worked example from your copy. Both are + there to be read once. Carrying them into every event file is the duplication + this folder exists to avoid. + +The underscore on `_template.md` keeps it at the top of the listing and marks it +as not being an event. The worked example inside it is an example and not the +record for that event, so one event one file still holds. Nothing reads this +folder in code, so the underscore is only for people. + +## What this folder is not + +It is not the design of the notification system. That is `NOTIFICATIONS.md` at +the repo root, and it covers the service, the types, the preferences and the +channels. A file in here is the record of one event: what sets it off, who hears +about it, and where to find the line that raises it. Keep the general +explanation out of it, there is one copy of that already. diff --git a/docs/notifications/events/_template.md b/docs/notifications/events/_template.md new file mode 100644 index 0000000000..d065829c67 --- /dev/null +++ b/docs/notifications/events/_template.md @@ -0,0 +1,119 @@ +# Event: + +| Field | Value | +|---|---| +| Event name | | +| Category | | +| What triggers it | | +| Who receives it | | +| Preference that gates it | | +| Email subject | | +| Email body summary | | +| Where it is raised | | + +## What goes in each field + +**Event name.** The exact string passed as `event:` to +`NotificationService.notify`. Lower case with underscores. It is also the file +name of this document and the name of the mailer templates, so get it right +once and it lines up everywhere. + +**Category.** The `type:` argument. One of `task`, `feedback`, `portfolio`, +`extension`, `general`, from `Notification::TYPES` in +`app/models/notification.rb`. The category is what the user's preference +switches on, so pick the one that matches how a user would think about turning +this off. + +**What triggers it.** The thing a person did, in a sentence. Then the method +that runs afterwards. "A tutor saves a text comment" is more use to the next +reader than "the comment callback fires". + +**Who receives it.** The `user:` argument, and how it is worked out. Say plainly +if it can be nil and what happens then. Most of the bugs in this area are a +recipient that was assumed to exist. + +**Preference that gates it.** The user column in +`Notification::PREFERENCE_FOR_TYPE` that the category maps to, spelled out in +full. Write `none, always sent` for `extension` and `general`, which have no +entry there. When the preference is off the notification is dropped on every +channel, the in-app bell included. + +**Email subject.** What lands in the inbox. Every notification shares one +subject built in `NotificationsMailer#single_notification`, so unless you +changed the mailer this is the same line as everyone else's. The product name +at the front is config and not a fixed word, so quote the line that builds it +and say what your stack sets it to. + +**Email body summary.** Two or three lines on what the email tells the reader, +and what it leaves out on purpose. Name the templates. If the event has no +templates of its own say so, the mailer falls back to the generic +`single_notification` pair and the email is much plainer. + +**Where it is raised.** `path/to/file.rb:`, the method it sits in, and +what calls that method. Line numbers move, so name the method too, that is the +part a reader can still find in six months. + +Anything else worth knowing goes below the table under its own headings. +Recipient guards, things left out on purpose, how to check it by hand, the test +file. Keep it short. + +--- + +# Worked example + +This is `task_comment_created`, the first event wired into OnTrack, from ticket +EN-E01. The code it describes lives on `email/task-comment` until that branch +merges, so read the paths below there. Copying the template gives you a copy of +this section and of the guidance above it. Delete both from your own file. + +| Field | Value | +|---|---| +| Event name | `task_comment_created` | +| Category | `feedback` | +| What triggers it | Someone saves a text comment on a task. `Task#add_text_comment` saves the comment and then calls `notify_comment_recipient` | +| Who receives it | `comment.recipient`, set by `add_text_comment` at `task.rb:945` and read here rather than worked out again. The student when a tutor commented. When a student commented it is `Project#tutor_for`, which gives the tutorial's tutor, or the unit's main convenor when there is no tutorial or the tutorial has no tutor | +| Preference that gates it | `receive_feedback_notifications` | +| Email subject | `#{product name}: New notification`, built at `app/mailers/notifications_mailer.rb:22`. `config/institution.yml` defaults the product name to `Doubtfire` and `DF_INSTITUTION_PRODUCT_NAME` overrides it. Our deploy sets `OnTrack`, so the inbox shows `OnTrack: New notification` | +| Email body summary | Greets the user by name, gives one line saying who commented on which task in which unit, then says the comment is not included and to open the task to read it. A link to the task and a line about turning the emails off. Templates are `app/views/notifications_mailer/task_comment_created.text.erb` and `.html.erb` | +| Where it is raised | `app/models/task.rb:971`, in `Task#notify_comment_recipient`, called from `add_text_comment` at line 949 | + +## Notes + +The comment text never goes into the message or the email. The email is a prompt +to come back to OnTrack, not a copy of the conversation. The message is built as +`"#{comment.user.name} commented on #{task_definition.abbreviation} in #{unit.code}."` +and the link is `/projects//dashboard/`. + +Raising a notification must not stop a comment being posted, so +`notify_comment_recipient` rescues `StandardError`, logs it and carries on. That +is a second layer. `NotificationService.deliver_email` already rescues mail +failures on its own. + +`notify_comment_recipient` returns early on a blank recipient. The comment in +the code says that happens when the project has no tutor, which is not right, +`Project#tutor_for` falls back to the main convenor. The guard is cheap +insurance rather than a case anyone has hit, and the test for it passes a nil +recipient in by hand instead of going through `add_text_comment`. + +The subject is generic on purpose. Per-event subjects need a lookup that every +event ticket would have to edit, which is the collision this folder exists to +avoid. It is a known limitation, left as it is for now. + +## How to check it by hand + +1. Sign in as a tutor, open a student's task and post a comment. +2. Development mail is written to a file, and `development/docker-compose.yml` + mounts `../data/tmp` over `/doubtfire/tmp`, so it lands on the host under + `doubtfire-deploy/data/tmp/mails/`. The file is named after the recipient's + address and every email to that address is appended to the same one. Open the + student's file and read the last message. It names the commenter and the task + and does not contain the comment text. +3. Turn that student's feedback notifications off in their profile and comment + again. Nothing is appended. Do not go looking for a new file, there is only + ever the one per address. + +## Tests + +`test/models/notification_task_comment_test.rb`. Both directions, the preference +switch, the missing recipient, the comment text staying out of the email, and a +notification failure still leaving the comment saved. From 33bd479afd0f24e7c4044cf791c6672ffef888ed Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Fri, 7 Aug 2026 00:29:47 +1000 Subject: [PATCH 015/247] test(notifications): add notification factory --- test/factories/notification_factory.rb | 40 ++++++++++++++++++++++++++ test/models/notification_test.rb | 30 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 test/factories/notification_factory.rb create mode 100644 test/models/notification_test.rb diff --git a/test/factories/notification_factory.rb b/test/factories/notification_factory.rb new file mode 100644 index 0000000000..923a1eacff --- /dev/null +++ b/test/factories/notification_factory.rb @@ -0,0 +1,40 @@ +require 'faker' + +FactoryBot.define do + factory :notification do + user + notification_type { 'general' } + event { "#{notification_type}_event" } + message { Faker::Lorem.sentence } + link { nil } + read_at { nil } + + trait :task do + notification_type { 'task' } + end + + trait :feedback do + notification_type { 'feedback' } + end + + trait :portfolio do + notification_type { 'portfolio' } + end + + trait :extension do + notification_type { 'extension' } + end + + trait :general do + notification_type { 'general' } + end + + trait :read do + read_at { Time.zone.now } + end + + trait :unread do + read_at { nil } + end + end +end diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb new file mode 100644 index 0000000000..fc995cfe40 --- /dev/null +++ b/test/models/notification_test.rb @@ -0,0 +1,30 @@ +require 'test_helper' + +class NotificationTest < ActiveSupport::TestCase + def test_the_factory_builds_a_valid_notification_for_every_category + Notification::TYPES.each do |type| + notification = FactoryBot.create(:notification, type.to_sym) + + assert notification.persisted?, "a #{type} notification did not save" + assert_equal type, notification.notification_type + assert_equal "#{type}_event", notification.event + assert notification.message.present? + end + end + + def test_an_unread_notification_is_in_the_unread_scope + notification = FactoryBot.create(:notification, :unread) + + assert_nil notification.read_at + assert_not notification.read? + assert_includes Notification.unread, notification + end + + def test_a_read_notification_is_out_of_the_unread_scope + notification = FactoryBot.create(:notification, :read) + + assert_not_nil notification.read_at + assert notification.read? + assert_not_includes Notification.unread, notification + end +end From 43ac0e31b344d4c16759ab5caf7686fb803feec8 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Fri, 7 Aug 2026 00:29:55 +1000 Subject: [PATCH 016/247] test(notifications): add push subscription factory --- test/api/push_subscriptions_api_test.rb | 57 +++++++++---------- test/factories/push_subscriptions_factory.rb | 18 ++++++ .../push_notification_service_test.rb | 16 ++---- 3 files changed, 52 insertions(+), 39 deletions(-) create mode 100644 test/factories/push_subscriptions_factory.rb diff --git a/test/api/push_subscriptions_api_test.rb b/test/api/push_subscriptions_api_test.rb index 596bb37af5..e44414fd52 100644 --- a/test/api/push_subscriptions_api_test.rb +++ b/test/api/push_subscriptions_api_test.rb @@ -15,21 +15,13 @@ def app @other = FactoryBot.create(:user, :student) end - # A plausible subscription. Real endpoints are long, which is the whole reason - # the column is not a default varchar(255). - def subscription_params(endpoint: 'https://fcm.googleapis.com/fcm/send/abc123') - { - endpoint: endpoint, - p256dh: 'BExampleBrowserPublicKeyValue', - auth: 'ExampleAuthSecret' - } - end - def test_a_user_can_register_a_browser + params = FactoryBot.attributes_for(:push_subscription) + add_auth_header_for(user: @user) assert_difference 'PushSubscription.count', 1 do - post '/api/push_subscriptions', subscription_params + post '/api/push_subscriptions', params end assert_equal 201, last_response.status @@ -37,26 +29,33 @@ def test_a_user_can_register_a_browser subscription = PushSubscription.last assert_equal @user, subscription.user - assert_equal 'https://fcm.googleapis.com/fcm/send/abc123', subscription.endpoint + assert_equal params[:endpoint], subscription.endpoint end def test_the_response_does_not_leak_the_browser_keys + params = FactoryBot.attributes_for(:push_subscription) + add_auth_header_for(user: @user) - post '/api/push_subscriptions', subscription_params + post '/api/push_subscriptions', params json = JSON.parse(last_response.body) - assert_equal 'https://fcm.googleapis.com/fcm/send/abc123', json['endpoint'] + assert_equal params[:endpoint], json['endpoint'] assert_not json.key?('p256dh'), 'the browser public key must not be sent back' assert_not json.key?('auth'), 'the browser auth secret must not be sent back' end + # Same params both times, so the endpoint has to be built once and reused. The + # factory sequences it, so calling the factory twice would be a different + # browser and this would test nothing. def test_registering_the_same_browser_twice_updates_instead_of_duplicating + params = FactoryBot.attributes_for(:push_subscription) + add_auth_header_for(user: @user) - post '/api/push_subscriptions', subscription_params + post '/api/push_subscriptions', params assert_no_difference 'PushSubscription.count' do - post '/api/push_subscriptions', subscription_params.merge(p256dh: 'BRotatedPublicKey') + post '/api/push_subscriptions', params.merge(p256dh: 'BRotatedPublicKey') end assert_equal 'BRotatedPublicKey', PushSubscription.last.p256dh @@ -65,12 +64,12 @@ def test_registering_the_same_browser_twice_updates_instead_of_duplicating # Shared machine. The endpoint belongs to the browser, so the registration has # to move to whoever signed in last rather than blowing up on the unique index. def test_registering_a_browser_another_user_had_moves_it_across - subscription = @other.push_subscriptions.create!(subscription_params) + subscription = FactoryBot.create(:push_subscription, user: @other) add_auth_header_for(user: @user) assert_no_difference 'PushSubscription.count' do - post '/api/push_subscriptions', subscription_params + post '/api/push_subscriptions', subscription.slice(:endpoint, :p256dh, :auth) end assert_equal @user, subscription.reload.user @@ -78,8 +77,8 @@ def test_registering_a_browser_another_user_had_moves_it_across end def test_a_user_only_sees_their_own_registrations - @user.push_subscriptions.create!(subscription_params(endpoint: 'https://push.example.com/mine')) - @other.push_subscriptions.create!(subscription_params(endpoint: 'https://push.example.com/theirs')) + mine = FactoryBot.create(:push_subscription, user: @user) + FactoryBot.create(:push_subscription, user: @other) add_auth_header_for(user: @user) get '/api/push_subscriptions' @@ -89,28 +88,28 @@ def test_a_user_only_sees_their_own_registrations json = JSON.parse(last_response.body) assert_equal 1, json.length - assert_equal 'https://push.example.com/mine', json.first['endpoint'] + assert_equal mine.endpoint, json.first['endpoint'] end def test_a_user_can_remove_their_own_registration - @user.push_subscriptions.create!(subscription_params) + subscription = FactoryBot.create(:push_subscription, user: @user) add_auth_header_for(user: @user) assert_difference 'PushSubscription.count', -1 do - delete '/api/push_subscriptions', endpoint: subscription_params[:endpoint] + delete '/api/push_subscriptions', endpoint: subscription.endpoint end assert_equal 200, last_response.status end def test_a_user_cannot_remove_someone_elses_registration - @other.push_subscriptions.create!(subscription_params) + subscription = FactoryBot.create(:push_subscription, user: @other) add_auth_header_for(user: @user) assert_no_difference 'PushSubscription.count' do - delete '/api/push_subscriptions', endpoint: subscription_params[:endpoint] + delete '/api/push_subscriptions', endpoint: subscription.endpoint end assert_equal 404, last_response.status @@ -120,7 +119,7 @@ def test_an_unauthenticated_request_is_rejected clear_auth_header assert_no_difference 'PushSubscription.count' do - post '/api/push_subscriptions', subscription_params + post '/api/push_subscriptions', FactoryBot.attributes_for(:push_subscription) end assert_equal 419, last_response.status @@ -130,7 +129,7 @@ def test_a_registration_missing_the_browser_keys_is_rejected add_auth_header_for(user: @user) assert_no_difference 'PushSubscription.count' do - post '/api/push_subscriptions', endpoint: 'https://push.example.com/incomplete' + post '/api/push_subscriptions', FactoryBot.attributes_for(:push_subscription).except(:p256dh, :auth) end assert_equal 400, last_response.status @@ -142,14 +141,14 @@ def test_a_long_endpoint_is_stored_whole long_endpoint = "https://updates.push.services.mozilla.com/wpush/v2/#{'a' * 300}" add_auth_header_for(user: @user) - post '/api/push_subscriptions', subscription_params(endpoint: long_endpoint) + post '/api/push_subscriptions', FactoryBot.attributes_for(:push_subscription, endpoint: long_endpoint) assert_equal 201, last_response.status assert_equal long_endpoint, PushSubscription.last.endpoint end def test_deleting_the_user_deletes_their_registrations - @user.push_subscriptions.create!(subscription_params) + FactoryBot.create(:push_subscription, user: @user) assert_difference 'PushSubscription.count', -1 do @user.destroy! diff --git a/test/factories/push_subscriptions_factory.rb b/test/factories/push_subscriptions_factory.rb new file mode 100644 index 0000000000..9b4a87c8c1 --- /dev/null +++ b/test/factories/push_subscriptions_factory.rb @@ -0,0 +1,18 @@ +FactoryBot.define do + factory :push_subscription do + user + + # The endpoint is unique across the whole table, not per user, so the + # sequence alone is not enough. A test that leaks a row past its transaction + # would leave that endpoint in the database for the next run and the + # collision would look like a bug in whatever test built it second. + sequence(:endpoint) { |n| "https://fcm.googleapis.com/fcm/send/factory-#{n}-#{SecureRandom.hex(8)}" } + + # A throwaway browser key pair. The p256dh has to be a real prime256v1 + # public key because PushNotificationService encrypts against it through the + # web-push gem, and the gem cannot encrypt to a made up string. The auth + # secret has to decode to 16 bytes for the same reason. + p256dh { 'BJy8RpjMkwOPDIIXSu-FTe7OosAwY9G86_evhrn0jJbPnoxXjBYpn7aPHEIaRh3GxCzFvwYXjKWvtu3FEMaBQMY=' } + auth { 'CUkmaYqq8eINt1HTnFY65w==' } + end +end diff --git a/test/services/push_notification_service_test.rb b/test/services/push_notification_service_test.rb index a5ae633b19..5b0582fdd1 100644 --- a/test/services/push_notification_service_test.rb +++ b/test/services/push_notification_service_test.rb @@ -7,14 +7,14 @@ # part most likely to be wired up wrong is the gem call and the payload, and a # stub of WebPush.payload_send would prove neither. class PushNotificationServiceTest < ActiveSupport::TestCase - # A throwaway VAPID pair and a throwaway browser key pair, generated for these - # tests. The p256dh has to be a real prime256v1 public key or the gem cannot - # encrypt the payload, so it cannot be a made up string. + # A throwaway VAPID pair, generated for these tests. The matching browser key + # pair lives in the push_subscription factory, which is where the real + # prime256v1 public key the gem needs to encrypt against now comes from. VAPID_PUBLIC = 'BOs-KbIoHK7gUIX3i2_uEuDoouj-GKxB-mY9CRmLNmd4Wn-SSl254E1g6jR1ukL3e37p8uCpaMjOvfAB0BwzvSI='.freeze VAPID_PRIVATE = '_NFIWSUTdCdLJJFh87pf4ekQLmNYqsweZ4288NpVZaY='.freeze - BROWSER_P256DH = 'BJy8RpjMkwOPDIIXSu-FTe7OosAwY9G86_evhrn0jJbPnoxXjBYpn7aPHEIaRh3GxCzFvwYXjKWvtu3FEMaBQMY='.freeze - BROWSER_AUTH = 'CUkmaYqq8eINt1HTnFY65w=='.freeze + # Fixed rather than sequenced, because every test here has to stub the exact + # URL the gem will post to. ENDPOINT = 'https://fcm.googleapis.com/fcm/send/test-browser'.freeze setup do @@ -51,11 +51,7 @@ def without_keys(&) end def create_subscription(endpoint: ENDPOINT) - @user.push_subscriptions.create!( - endpoint: endpoint, - p256dh: BROWSER_P256DH, - auth: BROWSER_AUTH - ) + FactoryBot.create(:push_subscription, user: @user, endpoint: endpoint) end def test_nothing_is_sent_when_the_vapid_keys_are_missing From 7fe0bbeec418a150bed8f2f59cc217c09296df28 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Fri, 7 Aug 2026 17:43:24 +1000 Subject: [PATCH 017/247] fix(notifications): restrict push endpoints to known services and add timeouts --- app/api/push_subscriptions_api.rb | 5 ++ app/models/push_subscription.rb | 69 ++++++++++++++++ app/services/push_notification_service.rb | 27 ++++++- test/models/push_subscription_test.rb | 78 +++++++++++++++++++ .../push_notification_service_test.rb | 49 ++++++++++++ 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 test/models/push_subscription_test.rb diff --git a/app/api/push_subscriptions_api.rb b/app/api/push_subscriptions_api.rb index 1b24f94081..b2c67158cc 100644 --- a/app/api/push_subscriptions_api.rb +++ b/app/api/push_subscriptions_api.rb @@ -13,6 +13,11 @@ class PushSubscriptionsApi < Grape::API present current_user.push_subscriptions.order(:id), with: Entities::PushSubscriptionEntity end + # The endpoint posted here is later used as the target of an outbound request + # by PushNotificationService, so it is not accepted as free text. + # PushSubscription validates it against PUSH_SERVICE_HOSTS, and a URL that is + # not an https push service URL fails with a 400 from the handler in + # api_root.rb. PushNotificationService checks again before it sends. desc 'Register this browser to receive push notifications' params do requires :endpoint, type: String, desc: 'The push service URL, from PushSubscription.endpoint' diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb index cc90bcdbd8..5354e0a749 100644 --- a/app/models/push_subscription.rb +++ b/app/models/push_subscription.rb @@ -4,10 +4,79 @@ # browser, not the person, so it is unique across the whole table: if the same # browser signs in as a different user the registration moves across instead of # being duplicated. PushSubscriptionsApi does that move. +# +# The endpoint arrives from the client and the api later makes an outbound POST +# to it, so it is not free text. It has to be an https URL belonging to a push +# service we recognise, or a signed in user could point the api at an internal +# host and use it to make requests on their behalf. See PUSH_SERVICE_HOSTS. class PushSubscription < ApplicationRecord + # Exact hosts. One per push service. + # + # fcm.googleapis.com Chrome, Edge, Opera, Brave + # android.googleapis.com older Chrome on Android + # updates.push.services.mozilla.com Firefox + # web.push.apple.com Safari, iOS 16.4+ + PUSH_SERVICE_HOSTS = %w[ + fcm.googleapis.com + android.googleapis.com + updates.push.services.mozilla.com + web.push.apple.com + ].freeze + + # Suffixes, for the services that shard across per-region subdomains. Matched + # with a leading dot so "evil-notify.windows.com" cannot pass as a subdomain + # of "notify.windows.com". + # + # *.notify.windows.com WNS, legacy Edge + # *.push.services.microsoft.com WNS, current + PUSH_SERVICE_HOST_SUFFIXES = %w[ + .notify.windows.com + .push.services.microsoft.com + ].freeze + belongs_to :user validates :endpoint, presence: true, uniqueness: true, length: { maximum: 500 } validates :p256dh, presence: true, length: { maximum: 255 } validates :auth, presence: true, length: { maximum: 255 } + + validate :endpoint_is_a_known_push_service + + # True when this endpoint is one we are willing to send to. + # + # Also called at delivery time, because rows written before this validation + # existed were never checked. Keep it a class method for that reason. + def self.push_service_endpoint?(endpoint) + return false if endpoint.blank? + + uri = URI.parse(endpoint.to_s) + + # https only. http would send the encrypted payload in the clear and is not + # something any real push service offers. + return false unless uri.is_a?(URI::HTTPS) + + # user:password@host is a redirect trick, and a non standard port is a sign + # somebody is aiming this somewhere it should not go. No push service uses + # either. + return false if uri.userinfo.present? + return false unless uri.port == 443 + + host = uri.host.to_s.downcase + return false if host.blank? + + PUSH_SERVICE_HOSTS.include?(host) || + PUSH_SERVICE_HOST_SUFFIXES.any? { |suffix| host.end_with?(suffix) } + rescue URI::InvalidURIError + false + end + + private + + def endpoint_is_a_known_push_service + return if endpoint.blank? # presence validation already covers this + + return if self.class.push_service_endpoint?(endpoint) + + errors.add(:endpoint, 'must be an https URL belonging to a recognised push service') + end end diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb index 365444ef08..f83c33e1f2 100644 --- a/app/services/push_notification_service.rb +++ b/app/services/push_notification_service.rb @@ -18,6 +18,19 @@ class PushNotificationService # task titles, so it is trimmed rather than trusted. MAX_BODY_LENGTH = 400 + # Seconds. web-push sets no timeouts of its own, so without these a push + # service that accepts a connection and then never answers holds the request + # thread open until the app server kills it. NotificationService calls this + # inline from the request path, so that stall is a stall for the person who + # posted the comment. + # + # Both are passed together on purpose. web-push 3.0.0 guards read_timeout on + # open_timeout being present (lib/web_push/request.rb line 15), so passing + # read_timeout alone silently does nothing. + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 5 + SSL_TIMEOUT = 5 + def self.deliver(notification) return unless configured? @@ -49,12 +62,24 @@ def self.payload_for(notification) end def self.deliver_to(subscription, payload) + # Checked again here rather than trusted from the row. PushSubscription + # validates this on write, but rows created before that validation existed + # were never checked, and this is the line that actually makes the outbound + # request. Refusing here is what stops a stored bad endpoint being used. + unless PushSubscription.push_service_endpoint?(subscription.endpoint) + Rails.logger.error "Refusing to push to subscription #{subscription.id}: endpoint is not a recognised push service" + return + end + WebPush.payload_send( message: payload, endpoint: subscription.endpoint, p256dh: subscription.p256dh, auth: subscription.auth, - vapid: vapid_details + vapid: vapid_details, + open_timeout: OPEN_TIMEOUT, + read_timeout: READ_TIMEOUT, + ssl_timeout: SSL_TIMEOUT ) rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription => e # 410 or 404. The browser has thrown this registration away, or it was never diff --git a/test/models/push_subscription_test.rb b/test/models/push_subscription_test.rb new file mode 100644 index 0000000000..f4d132e397 --- /dev/null +++ b/test/models/push_subscription_test.rb @@ -0,0 +1,78 @@ +require 'test_helper' + +# The endpoint arrives from the browser and PushNotificationService later makes +# an outbound POST to it, so anything that is not a real push service URL has to +# be refused on the way in. +class PushSubscriptionTest < ActiveSupport::TestCase + setup do + @user = FactoryBot.create(:user, :student) + end + + def build_with(endpoint) + FactoryBot.build(:push_subscription, user: @user, endpoint: endpoint) + end + + # Every service we actually expect to see, so a future change to the list + # cannot quietly drop a browser. + ACCEPTED = [ + 'https://fcm.googleapis.com/fcm/send/abc123', + 'https://android.googleapis.com/gcm/send/abc123', + 'https://updates.push.services.mozilla.com/wpush/v2/abc123', + 'https://web.push.apple.com/abc123', + 'https://par02p.notify.windows.com/w/?token=abc123', + 'https://wns2-by3p.push.services.microsoft.com/w/?token=abc123' + ].freeze + + ACCEPTED.each_with_index do |endpoint, index| + define_method("test_accepts_known_push_service_#{index}") do + subscription = build_with(endpoint) + + assert subscription.valid?, "#{endpoint} should be accepted but was rejected with #{subscription.errors.full_messages}" + end + end + + # The SSRF cases. Each of these is a host an attacker would want the api to + # make a request to on their behalf. + REJECTED = { + 'plain http' => 'http://fcm.googleapis.com/fcm/send/abc', + 'localhost' => 'https://localhost/fcm/send/abc', + 'loopback ip' => 'https://127.0.0.1/fcm/send/abc', + 'link local metadata' => 'https://169.254.169.254/latest/meta-data/', + 'private range' => 'https://10.0.0.5/internal', + 'the api container itself' => 'https://doubtfire-api:3000/api/users', + 'an arbitrary host' => 'https://example.com/push', + 'userinfo redirect trick' => 'https://fcm.googleapis.com@evil.example.com/push', + 'non standard port' => 'https://fcm.googleapis.com:8080/fcm/send/abc', + 'suffix lookalike' => 'https://evil-notify.windows.com/w/?token=abc', + 'host substring lookalike' => 'https://fcm.googleapis.com.evil.example.com/push', + 'not a url at all' => 'not a url', + 'file scheme' => 'file:///etc/passwd' + }.freeze + + REJECTED.each do |name, endpoint| + define_method("test_rejects_#{name.tr(' ', '_')}") do + subscription = build_with(endpoint) + + assert_not subscription.valid?, "#{endpoint} (#{name}) should have been rejected" + assert_includes subscription.errors[:endpoint].join, 'recognised push service' + end + end + + def test_the_factory_endpoint_is_accepted + # Guards against the allowlist and the factory drifting apart, which would + # break every other push test at once and look like an unrelated failure. + assert FactoryBot.build(:push_subscription, user: @user).valid? + end + + def test_push_service_endpoint_predicate_handles_blank_input + assert_not PushSubscription.push_service_endpoint?(nil) + assert_not PushSubscription.push_service_endpoint?('') + end + + def test_an_endpoint_is_still_required + subscription = build_with(nil) + + assert_not subscription.valid? + assert_includes subscription.errors[:endpoint].join, "can't be blank" + end +end diff --git a/test/services/push_notification_service_test.rb b/test/services/push_notification_service_test.rb index 5b0582fdd1..6a95a61a5f 100644 --- a/test/services/push_notification_service_test.rb +++ b/test/services/push_notification_service_test.rb @@ -173,4 +173,53 @@ def test_raising_a_notification_through_the_hub_sends_a_push assert_requested request end + + # Rows written before PushSubscription validated the endpoint were never + # checked, so the service refuses at send time as well. This is the line that + # actually stops a stored bad endpoint being used, so it is tested by writing + # a row that skips validation, the way an old row would look. + def test_a_stored_endpoint_that_is_not_a_push_service_is_never_requested + subscription = FactoryBot.build(:push_subscription, user: @user, endpoint: 'https://169.254.169.254/latest/meta-data/') + subscription.save!(validate: false) + + # No WebMock stub is registered for that host, so an outbound request would + # raise rather than pass silently. + with_keys { PushNotificationService.deliver(@notification) } + + assert_not_requested :post, 'https://169.254.169.254/latest/meta-data/' + assert subscription.reload.persisted?, 'a refused endpoint should be left alone, not treated as dead' + end + + def test_a_refused_endpoint_does_not_stop_the_other_browsers + FactoryBot + .build(:push_subscription, user: @user, endpoint: 'https://10.0.0.5/internal') + .save!(validate: false) + create_subscription + good = stub_request(:post, ENDPOINT).to_return(status: 201) + + with_keys { PushNotificationService.deliver(@notification) } + + assert_requested good + end + + # Timeouts are Net::HTTP settings rather than anything visible on the wire, so + # WebMock cannot see them. This one test stubs the gem instead of the HTTP + # call, which is the opposite of what the rest of this file does on purpose. + # + # It is worth the exception because web-push sets no timeouts of its own, and + # because the gem only applies read_timeout when open_timeout is also present + # (lib/web_push/request.rb line 15), so passing one without the other silently + # does nothing. + def test_both_timeouts_are_passed_to_the_gem + create_subscription + captured = nil + + WebPush.stub(:payload_send, ->(**args) { captured = args }) do + with_keys { PushNotificationService.deliver(@notification) } + end + + assert_equal PushNotificationService::OPEN_TIMEOUT, captured[:open_timeout] + assert_equal PushNotificationService::READ_TIMEOUT, captured[:read_timeout] + assert_equal PushNotificationService::SSL_TIMEOUT, captured[:ssl_timeout] + end end From fbd6c7c96265cb2bb6de7d5da96a5ecf25e133c9 Mon Sep 17 00:00:00 2001 From: Swyam Khare Date: Sun, 9 Aug 2026 15:48:50 +1000 Subject: [PATCH 018/247] feat(notifications): email on task status change EN-E02. When a staff member changes a task's status, email the task's student. Raised from Task#trigger_transition (notify_student_of_status_change) after the transition has succeeded and the status has been saved, only when a tutor acted (role == :tutor, never the student's own action) and the status actually changed. Recipient is always project.student. Built on the task_comment_created (EN-E01) pattern: type 'task' (so receive_task_notifications gates it), event 'task_status_changed', a message that names the actor/task/unit but never the new status value, and a link to the task on the student dashboard. The dedicated task_status_changed templates are picked up automatically by NotificationsMailer#event_template_name, so the mailer is not touched. A notification failure is logged and swallowed so it can never roll back the transition. Files: - app/models/task.rb: trigger + notify_student_of_status_change - app/views/notifications_mailer/task_status_changed.{html,text}.erb - test/models/notification_task_status_test.rb (9 tests) - docs/notifications/events/task_status_changed.md --- app/models/task.rb | 38 ++++++ .../task_status_changed.html.erb | 17 +++ .../task_status_changed.text.erb | 11 ++ .../events/task_status_changed.md | 78 +++++++++++ test/models/notification_task_status_test.rb | 127 ++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 app/views/notifications_mailer/task_status_changed.html.erb create mode 100644 app/views/notifications_mailer/task_status_changed.text.erb create mode 100644 docs/notifications/events/task_status_changed.md create mode 100644 test/models/notification_task_status_test.rb diff --git a/app/models/task.rb b/app/models/task.rb index 9bd0dbf1e2..34738cf065 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -605,6 +605,10 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: # State transitions based upon the trigger # + # Remember the status before the transition so we can tell, at the end, + # whether it actually changed. An unchanged status must not notify (EN-E02). + status_id_before_transition = task_status_id + status = TaskStatus.status_for_name(trigger) case status @@ -676,9 +680,43 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: end end + # EN-E02: tell the student when a staff member changed their task's status. + notify_student_of_status_change(by_user, role, status_id_before_transition) + true end + # Tell the student that a staff member changed the status of their task. + # + # Only a tutor's action notifies (role == :tutor); a student changing their + # own task must never email themselves. And only a real change notifies: an + # unchanged status is a no-op. + # + # The new status value is deliberately kept out of the notification, the same + # way the comment text is in notify_comment_recipient. The email is a prompt to + # come back to OnTrack, not a copy of the result. + # + # Raising a notification must never roll back the transition, so failures are + # logged and swallowed. NotificationService already rescues mail errors; this + # catches the record write and anything else unexpected. + def notify_student_of_status_change(by_user, role, previous_status_id) + return unless role == :tutor + return if task_status_id == previous_status_id + + recipient = project&.student + return if recipient.blank? || recipient == by_user + + NotificationService.notify( + user: recipient, + type: 'task', + event: 'task_status_changed', + message: "#{by_user.name} updated the status of #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise task_status_changed notification for task #{id}: #{e.message}" + end + def has_discussed_in_class_comment? comments.where(content_type: 'discussed_in_class').exists? end diff --git a/app/views/notifications_mailer/task_status_changed.html.erb b/app/views/notifications_mailer/task_status_changed.html.erb new file mode 100644 index 0000000000..4b19235318 --- /dev/null +++ b/app/views/notifications_mailer/task_status_changed.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The new status is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see it. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_status_changed.text.erb b/app/views/notifications_mailer/task_status_changed.text.erb new file mode 100644 index 0000000000..53f7f7f256 --- /dev/null +++ b/app/views/notifications_mailer/task_status_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The new status is not included in this email. Open the task in <%= @doubtfire_product_name %> to see it. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/docs/notifications/events/task_status_changed.md b/docs/notifications/events/task_status_changed.md new file mode 100644 index 0000000000..4525ed6d56 --- /dev/null +++ b/docs/notifications/events/task_status_changed.md @@ -0,0 +1,78 @@ +# Event: task_status_changed + +A staff member changes the status of a task. The student is told. Ticket EN-E02. + +Built the same way as the worked example in `task_comment_created.md`; read that +one first. + +## What it does + +A tutor marks a task, and the student whose task it is gets an email telling them +the status changed. + +- A tutor changes the status, the student is emailed. +- A student changing their own task is not emailed about their own action. + +## Where it is raised + +`app/models/task.rb`, in `notify_student_of_status_change`, called at the end of +`trigger_transition` once the transition has succeeded and the new status has +been saved. + + NotificationService.notify( + user: project.student, + type: 'task', + event: 'task_status_changed', + message: "#{by_user.name} updated the status of #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + +## Fields + +| Field | Value | +|---|---| +| `type` | `task`, so the student's `receive_task_notifications` switch controls it | +| `event` | `task_status_changed` | +| `message` | Who acted, which task, which unit. Never the new status value | +| `link` | `/projects//dashboard/` | + +## Templates + +- `app/views/notifications_mailer/task_status_changed.text.erb` +- `app/views/notifications_mailer/task_status_changed.html.erb` + +`NotificationsMailer#single_notification` picks the template named after the +event when it exists. Adding this event never required editing the mailer. + +## Three things to know before you copy this + +1. **Only a staff action notifies.** The guard is `role == :tutor`. A student + changing their own task (submitting, working on it) must never email + themselves. `role` is already worked out at the top of `trigger_transition`. + +2. **Only a real change notifies.** The status before the transition is captured + and compared at the end. Re-applying the same status is a no-op and sends + nothing. + +3. **A notification must never break the transition.** The call is wrapped in a + `rescue StandardError` that logs and swallows. Marking a task must succeed + even if notifying fails. + +## How to check it by hand + +1. Sign in as a tutor, open a student's task, change its status. +2. An email to the student arrives at http://localhost:8025 (Mailpit). It names + the tutor and the task, and does not contain the new status value. +3. Sign in as that student, change one of their own tasks, and confirm no email + is sent to themselves. +4. Turn that student's task notifications off in their profile, have the tutor + mark again, and no email arrives. + +## Tests + +`test/models/notification_task_status_test.rb` + +Covers the staff change, the student's own action sending nothing, an unchanged +status sending nothing, the preference switch, the status value staying out of +the email, the event-specific template being used, and that a notification +failure still leaves the transition committed. diff --git a/test/models/notification_task_status_test.rb b/test/models/notification_task_status_test.rb new file mode 100644 index 0000000000..f6fd9c8d28 --- /dev/null +++ b/test/models/notification_task_status_test.rb @@ -0,0 +1,127 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-E02: a staff status change notifies the student. A student's own action +# does not. +class NotificationTaskStatusTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task = @project.task_for_task_definition(@task_definition) + @student = @project.student + @tutor = @unit.main_convenor_user + + # Put the task where a tutor can mark it, then start from a clean inbox so + # the setup's own status comment does not count towards the assertions. + @task.update!(task_status: TaskStatus.ready_for_feedback) + @task.add_status_comment(@student, TaskStatus.ready_for_feedback) + ActionMailer::Base.deliveries.clear + end + + # The notification email is multipart, and Mail::Body#to_s is empty for a + # multipart body. Reading it the naive way makes every refute pass for the + # wrong reason, so decode the parts instead. + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_a_staff_status_change_notifies_the_student + assert_difference 'Notification.count', 1 do + assert @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'task', notification.notification_type + assert_equal 'task_status_changed', notification.event + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_a_students_own_action_notifies_nobody + assert_no_difference 'Notification.count' do + assert @task.trigger_transition(trigger: 'working_on_it', by_user: @student) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_an_unchanged_status_notifies_nobody + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + ActionMailer::Base.deliveries.clear + + # Re-applying the same status is a no-op: no change, no notification. + assert_no_difference 'Notification.count' do + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_no_notification_when_the_task_preference_is_off + @student.update!(receive_task_notifications: false) + + assert_no_difference 'Notification.count' do + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_the_status_value_is_not_in_the_notification_or_the_email + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + + notification = Notification.recent_first.first + body = delivered_body + + assert_not_empty body, 'guard: the body must be readable or this test proves nothing' + assert_not_includes notification.message, 'Discuss' + assert_not_includes body, 'Discuss' + end + + def test_the_message_names_the_actor_and_the_task + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + + message = Notification.recent_first.first.message + + assert_includes message, @tutor.name + assert_includes message, @task_definition.abbreviation + end + + def test_the_link_points_at_the_task_on_the_student_dashboard + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + + assert_equal( + "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}", + Notification.recent_first.first.link + ) + end + + def test_the_event_specific_template_is_used_instead_of_the_generic_one + @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + + body = delivered_body + + # Wording that only exists in task_status_changed.*.erb. If the mailer ever + # falls back to single_notification.*.erb this fails. + assert_includes body, 'The new status is not included in this email' + end + + def test_a_notification_failure_does_not_stop_the_transition + result = nil + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification exploded' } do + result = @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + end + + assert result, 'the transition must still succeed' + assert_equal TaskStatus.discuss, @task.reload.task_status + end +end From 0b977605bdbb75b906295f1201fd3d31921b9f06 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 06:47:02 +1000 Subject: [PATCH 019/247] feat(ppi): add peer progress snapshot persistence --- app/models/peer_progress_snapshot.rb | 84 +++++++ app/models/task_definition.rb | 3 +- app/models/unit.rb | 1 + ...09153000_create_peer_progress_snapshots.rb | 30 +++ db/schema.rb | 16 +- .../peer_progress_snapshot_factory.rb | 26 ++ test/models/peer_progress_snapshot_test.rb | 226 ++++++++++++++++++ 7 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 app/models/peer_progress_snapshot.rb create mode 100644 db/migrate/20260809153000_create_peer_progress_snapshots.rb create mode 100644 test/factories/peer_progress_snapshot_factory.rb create mode 100644 test/models/peer_progress_snapshot_test.rb diff --git a/app/models/peer_progress_snapshot.rb b/app/models/peer_progress_snapshot.rb new file mode 100644 index 0000000000..425a5de18b --- /dev/null +++ b/app/models/peer_progress_snapshot.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class PeerProgressSnapshot < ApplicationRecord + belongs_to :unit, + inverse_of: :peer_progress_snapshots + + belongs_to :task_definition, + inverse_of: :peer_progress_snapshots + + validates :target_grade, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + }, + uniqueness: { + scope: %i[unit_id task_definition_id] + } + + validates :submitted_percentage, + numericality: { + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 + }, + allow_nil: true + + validates :cohort_size, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + } + + validates :calculated_at, + presence: true + + validate :task_definition_belongs_to_unit + validate :target_grade_enabled_for_unit + validate :target_grade_covers_task + validate :percentage_requires_non_empty_cohort + + private + + def task_definition_belongs_to_unit + return if unit.blank? || task_definition.blank? + return if task_definition.unit_id == unit_id + + errors.add( + :task_definition, + 'must belong to the same unit' + ) + end + + def target_grade_enabled_for_unit + return if unit.blank? || target_grade.nil? + return if unit.grade_value?(target_grade) + + errors.add( + :target_grade, + 'must be enabled for the unit' + ) + end + + def target_grade_covers_task + return if task_definition.blank? || target_grade.nil? + return if target_grade >= task_definition.target_grade + + errors.add( + :target_grade, + 'must be at least the task definition target grade' + ) + end + + def percentage_requires_non_empty_cohort + return if submitted_percentage.nil? + return if cohort_size.nil? + return if cohort_size.positive? + + errors.add( + :submitted_percentage, + 'must be blank when cohort size is zero' + ) + end +end diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 7ef377811f..cbf7be1ce3 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -70,7 +70,8 @@ def self.permissions belongs_to :tutorial_stream, optional: true belongs_to :overseer_image, optional: true - has_many :tasks, dependent: :destroy # Destroying a task definition will also nuke any instances + has_many :tasks, dependent: :destroy # Destroying a task definition will also nuke any instances + has_many :peer_progress_snapshots, dependent: :destroy, inverse_of: :task_definition has_many :group_submissions, dependent: :destroy # Destroying a task definition will also nuke any group submissions has_many :learning_outcomes, as: :context, dependent: :destroy has_many :overseer_steps, -> { order(:sort_order) }, inverse_of: :task_definition, dependent: :destroy diff --git a/app/models/unit.rb b/app/models/unit.rb index 19e0098298..f4dead29eb 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -174,6 +174,7 @@ def role_for(user) has_many :learning_outcomes, as: :context, dependent: :destroy # inverse_of: :unit has_many :marking_sessions, dependent: :destroy has_many :task_completion_snapshots, dependent: :destroy, inverse_of: :unit + has_many :peer_progress_snapshots, dependent: :destroy, inverse_of: :unit has_many :communication_sets, class_name: 'CommunicationSet', dependent: :destroy has_many :communication_rules, through: :communication_sets, class_name: 'CommunicationRule' has_many :communication_set_schedules, through: :communication_sets, class_name: 'CommunicationSetSchedule' diff --git a/db/migrate/20260809153000_create_peer_progress_snapshots.rb b/db/migrate/20260809153000_create_peer_progress_snapshots.rb new file mode 100644 index 0000000000..b86502ee0c --- /dev/null +++ b/db/migrate/20260809153000_create_peer_progress_snapshots.rb @@ -0,0 +1,30 @@ +class CreatePeerProgressSnapshots < ActiveRecord::Migration[8.0] + def change + create_table :peer_progress_snapshots do |t| + t.references :unit, null: false + t.references :task_definition, null: false + + t.integer :target_grade, null: false + + # nil represents suppressed or unavailable data. + # A genuine zero result is stored as 0.00. + t.decimal :submitted_percentage, + precision: 5, + scale: 2 + + # Internal only. Never expose this raw value through the student API. + t.integer :cohort_size, null: false + + # The time the aggregate was calculated, rather than when this row + # happened to be inserted or updated. + t.datetime :calculated_at, null: false + + t.timestamps + end + + add_index :peer_progress_snapshots, + [:unit_id, :task_definition_id, :target_grade], + unique: true, + name: 'idx_peer_progress_unit_task_grade' + end +end diff --git a/db/schema.rb b/db/schema.rb index b8ec5659b3..b1adfb5648 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_09_014859) do +ActiveRecord::Schema[8.0].define(version: 2026_08_09_153000) 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 @@ -443,6 +443,20 @@ t.index ["task_definition_id"], name: "index_overseer_steps_on_task_definition_id" end + create_table "peer_progress_snapshots", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.bigint "task_definition_id", null: false + t.integer "target_grade", null: false + t.decimal "submitted_percentage", precision: 5, scale: 2 + t.integer "cohort_size", null: false + t.datetime "calculated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["task_definition_id"], name: "index_peer_progress_snapshots_on_task_definition_id" + t.index ["unit_id", "task_definition_id", "target_grade"], name: "idx_peer_progress_unit_task_grade", unique: true + t.index ["unit_id"], name: "index_peer_progress_snapshots_on_unit_id" + end + create_table "projects", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id" t.string "project_role" diff --git a/test/factories/peer_progress_snapshot_factory.rb b/test/factories/peer_progress_snapshot_factory.rb new file mode 100644 index 0000000000..809fd565de --- /dev/null +++ b/test/factories/peer_progress_snapshot_factory.rb @@ -0,0 +1,26 @@ +FactoryBot.define do + factory :peer_progress_snapshot do + unit do + create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0 + ) + end + + task_definition do + create( + :task_definition, + unit: unit, + target_grade: 0, + outcome_count: 0 + ) + end + + target_grade { task_definition.target_grade } + submitted_percentage { 50.0 } + cohort_size { 10 } + calculated_at { Time.current } + end +end diff --git a/test/models/peer_progress_snapshot_test.rb b/test/models/peer_progress_snapshot_test.rb new file mode 100644 index 0000000000..85e29b0e2f --- /dev/null +++ b/test/models/peer_progress_snapshot_test.rb @@ -0,0 +1,226 @@ +require 'test_helper' + +class PeerProgressSnapshotTest < ActiveSupport::TestCase + setup do + @unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0 + ) + + @task_definition = create( + :task_definition, + unit: @unit, + target_grade: 0, + outcome_count: 0 + ) + end + + test 'is valid with the required aggregate fields' do + assert build_snapshot.valid? + end + + test 'belongs to its unit and task definition' do + snapshot = create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition + ) + + assert_equal @unit, snapshot.unit + assert_equal @task_definition, snapshot.task_definition + end + + test 'requires a calculation timestamp' do + snapshot = build_snapshot(calculated_at: nil) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:calculated_at], + "can't be blank" + ) + end + + test 'accepts a genuine zero percentage for a non-empty cohort' do + snapshot = build_snapshot( + submitted_percentage: 0, + cohort_size: 10 + ) + + assert snapshot.valid? + end + + test 'accepts a nil percentage for unavailable or suppressed data' do + suppressed = build_snapshot( + submitted_percentage: nil, + cohort_size: 3 + ) + + unavailable = build_snapshot( + submitted_percentage: nil, + cohort_size: 0 + ) + + assert suppressed.valid? + assert unavailable.valid? + end + + test 'rejects percentages outside zero to one hundred' do + below_zero = build_snapshot( + submitted_percentage: -0.01 + ) + + above_one_hundred = build_snapshot( + submitted_percentage: 100.01 + ) + + assert_not below_zero.valid? + assert_not above_one_hundred.valid? + end + + test 'requires a non-negative integer cohort size' do + negative = build_snapshot(cohort_size: -1) + decimal = build_snapshot(cohort_size: 2.5) + + assert_not negative.valid? + assert_not decimal.valid? + end + + test 'does not allow a percentage when cohort size is zero' do + snapshot = build_snapshot( + submitted_percentage: 0, + cohort_size: 0 + ) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:submitted_percentage], + 'must be blank when cohort size is zero' + ) + end + + test 'requires the task definition to belong to the same unit' do + other_unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0 + ) + + snapshot = build_snapshot(unit: other_unit) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:task_definition], + 'must belong to the same unit' + ) + end + + test 'requires a target grade enabled for the unit' do + snapshot = build_snapshot(target_grade: 99) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:target_grade], + 'must be enabled for the unit' + ) + end + + test 'requires the cohort grade to cover the task target grade' do + higher_grade_task = create( + :task_definition, + unit: @unit, + target_grade: 2, + outcome_count: 0 + ) + + snapshot = build_snapshot( + task_definition: higher_grade_task, + target_grade: 1 + ) + + assert_not snapshot.valid? + + assert_includes( + snapshot.errors[:target_grade], + 'must be at least the task definition target grade' + ) + end + + test 'enforces one snapshot per unit task and target grade' do + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: 0 + ) + + duplicate = build_snapshot(target_grade: 0) + + assert_not duplicate.valid? + + assert_includes( + duplicate.errors[:target_grade], + 'has already been taken' + ) + end + + test 'allows another target grade for the same unit and task' do + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: 0 + ) + + second_grade = build_snapshot(target_grade: 1) + + assert second_grade.valid?, + second_grade.errors.full_messages.to_sentence + end + + test 'database index rejects duplicate aggregate keys' do + snapshot = create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: 0 + ) + + duplicate = snapshot.dup + + assert_raises ActiveRecord::RecordNotUnique do + duplicate.save!(validate: false) + end + end + + test 'destroying a task definition destroys its snapshots' do + snapshot = create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition + ) + + snapshot_id = snapshot.id + + @task_definition.destroy! + + assert_not PeerProgressSnapshot.exists?(snapshot_id) + end + + private + + def build_snapshot(**overrides) + build( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + **overrides + ) + end +end From 4a987041bdd0ee4c222885afaca098088519e06c Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 08:40:15 +1000 Subject: [PATCH 020/247] feat(ppi): calculate peer progress snapshots --- .../peer_progress_aggregation_service.rb | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 app/services/peer_progress_aggregation_service.rb diff --git a/app/services/peer_progress_aggregation_service.rb b/app/services/peer_progress_aggregation_service.rb new file mode 100644 index 0000000000..30bbf16d7a --- /dev/null +++ b/app/services/peer_progress_aggregation_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# Calculates and stores task-level peer-progress snapshots for one unit. +# +# This service stores aggregate values only. It does not authorise students, +# apply the small-cohort display threshold, or expose API response data. +class PeerProgressAggregationService + def self.call(unit:, calculated_at: Time.zone.now) + new(unit: unit, calculated_at: calculated_at).call + end + + def initialize(unit:, calculated_at:) + unless unit.is_a?(Unit) && unit.persisted? + raise ArgumentError, 'unit must be a persisted Unit' + end + raise ArgumentError, 'calculated_at is required' if calculated_at.blank? + + @unit = unit + @calculated_at = calculated_at + end + + def call + snapshots = [] + + PeerProgressSnapshot.transaction do + existing_snapshots = PeerProgressSnapshot.where(unit: unit).index_by do |snapshot| + [snapshot.task_definition_id, snapshot.target_grade] + end + + unit.grade_values.map(&:to_i).uniq.sort.each do |target_grade| + cohort = unit.active_projects.where(target_grade: target_grade) + cohort_size = cohort.count + + task_definitions = unit.task_definitions + .where('target_grade <= ?', target_grade) + .order(:id) + + submitted_counts = submitted_counts_for( + cohort: cohort, + task_definitions: task_definitions + ) + + task_definitions.each do |task_definition| + key = [task_definition.id, target_grade] + + snapshot = existing_snapshots[key] || PeerProgressSnapshot.new( + unit: unit, + task_definition: task_definition, + target_grade: target_grade + ) + + snapshot.assign_attributes( + cohort_size: cohort_size, + submitted_percentage: percentage( + submitted_count: submitted_counts.fetch(task_definition.id, 0), + cohort_size: cohort_size + ), + calculated_at: calculated_at + ) + + snapshot.save! + snapshots << snapshot + end + end + end + + snapshots + end + + private + + attr_reader :unit, :calculated_at + + def submitted_counts_for(cohort:, task_definitions:) + Task + .where( + project_id: cohort.select(:id), + task_definition_id: task_definitions.select(:id) + ) + .where.not(submission_date: nil) + .group(:task_definition_id) + .distinct + .count(:project_id) + end + + def percentage(submitted_count:, cohort_size:) + return nil if cohort_size.zero? + + ((submitted_count * 100.0) / cohort_size).round(2) + end +end From 8d7065bcfa15cc17b2d55f80358a3ccda8e5609c Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 08:40:41 +1000 Subject: [PATCH 021/247] test(ppi): cover peer progress aggregation --- .../peer_progress_aggregation_service_test.rb | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 test/services/peer_progress_aggregation_service_test.rb diff --git a/test/services/peer_progress_aggregation_service_test.rb b/test/services/peer_progress_aggregation_service_test.rb new file mode 100644 index 0000000000..23cd2302ac --- /dev/null +++ b/test/services/peer_progress_aggregation_service_test.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PeerProgressAggregationServiceTest < ActiveSupport::TestCase + def setup + @unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 + ) + + @pass_task = create( + :task_definition, + unit: @unit, + target_grade: 0, + outcome_count: 0 + ) + + @credit_task = create( + :task_definition, + unit: @unit, + target_grade: 1, + outcome_count: 0 + ) + + @calculated_at = Time.zone.parse('2026-08-10 10:00:00') + end + + def test_calculates_percentage_for_enrolled_projects_in_the_same_target_grade + projects = create_list( + :project, + 4, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + projects.first(3).each do |project| + create_submitted_task( + project: project, + task_definition: @pass_task + ) + end + + other_grade = create( + :project, + unit: @unit, + target_grade: 1, + enrolled: true + ) + + create_submitted_task( + project: other_grade, + task_definition: @pass_task + ) + + withdrawn = create( + :project, + unit: @unit, + target_grade: 0, + enrolled: false + ) + + create_submitted_task( + project: withdrawn, + task_definition: @pass_task + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 4, snapshot.cohort_size + assert_equal 75.0, snapshot.submitted_percentage.to_f + assert_equal @calculated_at, snapshot.calculated_at + end + + def test_returns_a_genuine_zero_when_the_cohort_exists_but_nobody_has_submitted + create_list( + :project, + 4, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 4, snapshot.cohort_size + assert_equal 0.0, snapshot.submitted_percentage.to_f + end + + def test_returns_nil_percentage_when_the_cohort_is_empty + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 3 + ) + + assert_equal 0, snapshot.cohort_size + assert_nil snapshot.submitted_percentage + end + + def test_only_creates_snapshots_for_tasks_applicable_to_the_target_grade + create( + :project, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create( + :project, + unit: @unit, + target_grade: 1, + enrolled: true + ) + + run_service + + assert PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @pass_task, + target_grade: 0 + ) + + assert_not PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @credit_task, + target_grade: 0 + ) + + assert PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @pass_task, + target_grade: 1 + ) + + assert PeerProgressSnapshot.exists?( + unit: @unit, + task_definition: @credit_task, + target_grade: 1 + ) + end + + def test_counts_uploads_regardless_of_the_current_task_status + statuses = [ + TaskStatus.ready_for_feedback, + TaskStatus.complete, + TaskStatus.redo, + TaskStatus.fix_and_resubmit + ] + + projects = create_list( + :project, + statuses.length, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + projects.zip(statuses).each do |project, status| + create_submitted_task( + project: project, + task_definition: @pass_task, + task_status: status + ) + end + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal statuses.length, snapshot.cohort_size + assert_equal 100.0, snapshot.submitted_percentage.to_f + end + + def test_does_not_count_a_task_without_a_submission_date + projects = create_list( + :project, + 2, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create_submitted_task( + project: projects.first, + task_definition: @pass_task + ) + + create( + :task, + project: projects.second, + task_definition: @pass_task, + task_status: TaskStatus.complete, + submission_date: nil + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 2, snapshot.cohort_size + assert_equal 50.0, snapshot.submitted_percentage.to_f + end + + def test_does_not_create_missing_task_rows + create_list( + :project, + 2, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + assert_no_difference('Task.count') do + run_service + end + end + + def test_updates_existing_snapshots_without_creating_duplicates + projects = create_list( + :project, + 2, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + run_service + snapshot_count = PeerProgressSnapshot.count + + create_submitted_task( + project: projects.first, + task_definition: @pass_task + ) + + PeerProgressAggregationService.call( + unit: @unit, + calculated_at: @calculated_at + 1.hour + ) + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal snapshot_count, PeerProgressSnapshot.count + assert_equal 50.0, snapshot.submitted_percentage.to_f + assert_equal @calculated_at + 1.hour, snapshot.calculated_at + end + + def test_rounds_percentages_to_two_decimal_places + projects = create_list( + :project, + 3, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create_submitted_task( + project: projects.first, + task_definition: @pass_task + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 33.33, snapshot.submitted_percentage.to_f + end + + private + + def run_service + PeerProgressAggregationService.call( + unit: @unit, + calculated_at: @calculated_at + ) + end + + def find_snapshot(task_definition:, target_grade:) + PeerProgressSnapshot.find_by!( + unit: @unit, + task_definition: task_definition, + target_grade: target_grade + ) + end + + def create_submitted_task( + project:, + task_definition:, + task_status: TaskStatus.ready_for_feedback + ) + create( + :task, + project: project, + task_definition: task_definition, + task_status: task_status, + submission_date: @calculated_at - 1.hour + ) + end +end \ No newline at end of file From 36c03f378be21725a78ab52ade934f72f495e144 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 12:09:04 +1000 Subject: [PATCH 022/247] feat(ppi): schedule peer progress aggregation --- app/sidekiq/aggregate_peer_progress_job.rb | 52 ++++++++++++++++++++++ config/schedule.yml | 4 ++ 2 files changed, 56 insertions(+) create mode 100644 app/sidekiq/aggregate_peer_progress_job.rb diff --git a/app/sidekiq/aggregate_peer_progress_job.rb b/app/sidekiq/aggregate_peer_progress_job.rb new file mode 100644 index 0000000000..98b220f196 --- /dev/null +++ b/app/sidekiq/aggregate_peer_progress_job.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class AggregatePeerProgressJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id = nil) + logger.info 'Starting peer progress aggregation...' + + at(0) + total(1) + + calculated_at = Time.zone.now + + if unit_id.present? + aggregate_unit(Unit.find(unit_id), calculated_at) + else + Unit.active_units.find_each do |unit| + aggregate_unit(unit, calculated_at) + end + end + + at(1) + logger.info 'Completed peer progress aggregation!' + rescue StandardError => e + logger.error "Peer progress aggregation failed: #{e.class}: #{e.message}" + raise + end + + private + + def aggregate_unit(unit, calculated_at) + unless unit.active? + logger.info( + "Skipping peer progress aggregation for inactive unit_id=#{unit.id}" + ) + return + end + + PeerProgressAggregationService.call( + unit: unit, + calculated_at: calculated_at + ) + end +end diff --git a/config/schedule.yml b/config/schedule.yml index 62fd893daf..f6aee20e66 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -16,6 +16,10 @@ refresh_moderation_feedback_timestamps: cron: "every 60 minutes" class: "RefreshModerationFeedbackTimestampsJob" +aggregate_peer_progress: + cron: "every day at 11:45pm" + class: "AggregatePeerProgressJob" + aggregate_task_completion_stats: cron: "every day at 11:55pm" class: "AggregateTaskCompletionStatsJob" From 004ddf75204bf147354d6803a6033027e7cbacc4 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 12:09:37 +1000 Subject: [PATCH 023/247] test(ppi): cover peer progress aggregation job --- .../aggregate_peer_progress_job_test.rb | 126 ++++++++++++++++++ test/sidekiq/scheduled_job_test.rb | 25 +++- 2 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 test/sidekiq/aggregate_peer_progress_job_test.rb diff --git a/test/sidekiq/aggregate_peer_progress_job_test.rb b/test/sidekiq/aggregate_peer_progress_job_test.rb new file mode 100644 index 0000000000..31ce3c0304 --- /dev/null +++ b/test/sidekiq/aggregate_peer_progress_job_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' + +class AggregatePeerProgressJobTest < ActiveSupport::TestCase + def setup + @active_unit = create_minimal_unit(active: true) + @inactive_unit = create_minimal_unit(active: false) + @calculated_at = Time.zone.parse('2026-08-10 23:45:00') + end + + def test_aggregates_the_requested_active_unit + calls = [] + + travel_to @calculated_at do + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, calculated_at:| + calls << { + unit: unit, + calculated_at: calculated_at + } + [] + end + ) do + AggregatePeerProgressJob.new.perform(@active_unit.id) + end + end + + assert_equal 1, calls.length + assert_equal @active_unit, calls.first[:unit] + assert_equal @calculated_at, calls.first[:calculated_at] + end + + def test_aggregates_all_active_units_when_no_unit_id_is_given + expected_unit_ids = Unit.active_units.order(:id).pluck(:id) + calls = [] + + travel_to @calculated_at do + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, calculated_at:| + calls << { + unit_id: unit.id, + calculated_at: calculated_at + } + [] + end + ) do + AggregatePeerProgressJob.new.perform + end + end + + actual_unit_ids = calls.map { |call| call[:unit_id] }.sort + calculated_times = calls.map { |call| call[:calculated_at] }.uniq + + assert_equal expected_unit_ids, actual_unit_ids + assert_equal [@calculated_at], calculated_times + assert_not_includes expected_unit_ids, @inactive_unit.id + end + + def test_skips_a_requested_inactive_unit + calls = [] + + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, calculated_at:| + calls << [unit, calculated_at] + [] + end + ) do + AggregatePeerProgressJob.new.perform(@inactive_unit.id) + end + + assert_empty calls + end + + def test_raises_when_requested_unit_does_not_exist + missing_unit_id = Unit.maximum(:id).to_i + 10_000 + + assert_raises(ActiveRecord::RecordNotFound) do + AggregatePeerProgressJob.new.perform(missing_unit_id) + end + end + + def test_reraises_aggregation_errors + PeerProgressAggregationService.stub( + :call, + lambda do |**_kwargs| + raise StandardError, 'aggregation failed' + end + ) do + error = assert_raises(StandardError) do + AggregatePeerProgressJob.new.perform(@active_unit.id) + end + + assert_equal 'aggregation failed', error.message + end + end + + def test_enqueues_only_the_unit_id + assert_difference -> { AggregatePeerProgressJob.jobs.size }, 1 do + AggregatePeerProgressJob.perform_async(@active_unit.id) + end + + queued_job = AggregatePeerProgressJob.jobs.last + + assert_equal [@active_unit.id], queued_job['args'] + end + + private + + def create_minimal_unit(active:) + create( + :unit, + active: active, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 + ) + end +end diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index e21285fdf3..f921bff57b 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -1,21 +1,36 @@ # frozen_string_literal: true require 'test_helper' -class TiiCheckProgressJobTest < ActiveSupport::TestCase +require 'sidekiq_unique_jobs/testing' +class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled + # Clear fake jobs and any unique-job locks left by an earlier test run. + Sidekiq::Job.clear_all Sidekiq::Cron::Job.destroy_all! - Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 6, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) - Sidekiq::Cron::Job.all.each(&:enqueue!) + Sidekiq::Cron::Job.load_from_hash!( + YAML.load_file(Rails.root.join('config/schedule.yml')) + ) + + jobs = Sidekiq::Cron::Job.all + peer_progress_job = + jobs.find { |job| job.name == 'aggregate_peer_progress' } + + assert_equal 7, jobs.count, jobs.map(&:name) + assert_not_nil peer_progress_job + assert_equal 'AggregatePeerProgressJob', peer_progress_job.klass + + # Sidekiq::Cron::Job.all returns an Array, not an ActiveRecord relation. + jobs.each(&:enqueue!) + assert_equal 1, TiiRegisterWebHookJob.jobs.count assert_equal 1, TiiCheckProgressJob.jobs.count assert_equal 1, ClearAccessTokensJob.jobs.count assert_equal 1, RefreshModerationFeedbackTimestampsJob.jobs.count + assert_equal 1, AggregatePeerProgressJob.jobs.count assert_equal 1, AggregateTaskCompletionStatsJob.jobs.count assert_equal 1, PollCommunicationSetSchedulesJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end - end From a1632f96c3ec6c695155db48c634fdce07c86911 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 15:28:04 +1000 Subject: [PATCH 024/247] feat(ppi): add authorised student progress endpoint --- app/api/api_root.rb | 2 + app/api/peer_progress_api.rb | 167 ++++++++++++++++++ ...3824_add_peer_progress_enabled_to_units.rb | 11 ++ db/schema.rb | 3 +- 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 app/api/peer_progress_api.rb create mode 100644 db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb diff --git a/app/api/api_root.rb b/app/api/api_root.rb index 3dbc682297..beba042e21 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -66,6 +66,7 @@ class ApiRoot < Grape::API mount GroupSetsApi mount LearningOutcomesApi mount ProjectsApi + mount PeerProgressApi mount SettingsApi mount StudentsApi mount Submission::PortfolioApi @@ -125,6 +126,7 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to GroupSetsApi AuthenticationHelpers.add_auth_to LearningOutcomesApi AuthenticationHelpers.add_auth_to ProjectsApi + AuthenticationHelpers.add_auth_to PeerProgressApi AuthenticationHelpers.add_auth_to StudentsApi AuthenticationHelpers.add_auth_to Submission::PortfolioApi AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb new file mode 100644 index 0000000000..58d1c8b71d --- /dev/null +++ b/app/api/peer_progress_api.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require 'grape' + +class PeerProgressApi < Grape::API + helpers AuthenticationHelpers + + UNAVAILABLE_MESSAGE = 'Peer progress is currently unavailable.' + NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.' + CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.' + + before do + authenticated? + end + + helpers do + def peer_progress_not_found! + error!({ error: PeerProgressApi::NOT_FOUND_MESSAGE }, 404) + end + + def positive_integer_env!(name) + value = Integer(ENV.fetch(name), 10) + raise ArgumentError unless value.positive? + + value + rescue KeyError, ArgumentError + error!({ error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, 503) + end + + def peer_progress_payload( + project:, + task_definition:, + snapshot: nil, + submitted_percentage: nil, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + unavailable_message: '' + ) + { + task_definition_id: task_definition.id, + unit_id: project.unit_id, + target_grade: project.target_grade, + submitted_percentage: submitted_percentage, + is_suppressed: is_suppressed, + is_stale: is_stale, + is_feature_enabled: is_feature_enabled, + last_updated_at: snapshot&.calculated_at&.iso8601, + unavailable_message: unavailable_message + } + end + + def peer_progress_result(project:, task_definition:) + unit = project.unit + + unless unit.peer_progress_enabled? + return peer_progress_payload( + project: project, + task_definition: task_definition, + is_feature_enabled: false, + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + target_grade = project.target_grade + unless target_grade.present? && unit.grade_value?(target_grade) + return peer_progress_payload( + project: project, + task_definition: task_definition, + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + snapshot = unit.peer_progress_snapshots.find_by( + task_definition_id: task_definition.id, + target_grade: target_grade + ) + + if snapshot.nil? || snapshot.cohort_size.zero? || + snapshot.submitted_percentage.nil? + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + minimum_cohort_size = positive_integer_env!( + 'DF_PPI_MINIMUM_COHORT_SIZE' + ) + stale_after_hours = positive_integer_env!( + 'DF_PPI_STALE_AFTER_HOURS' + ) + + if snapshot.cohort_size < minimum_cohort_size + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_suppressed: true, + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + if snapshot.calculated_at < stale_after_hours.hours.ago + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_stale: true, + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + submitted_percentage: snapshot.submitted_percentage.to_f + ) + end + end + + desc 'Get anonymous task-level peer progress for the authenticated student', + tags: ['peer_progress'], + summary: 'Get anonymous task-level peer progress' + params do + requires :id, + type: Integer, + desc: 'The authenticated student project ID' + requires :task_definition_id, + type: Integer, + desc: 'The task definition ID' + end + get '/projects/:id/task_def_id/:task_definition_id/peer_progress' do + peer_progress_not_found! if current_user.role.id != Role.student_id + + project = Project.for_user(current_user, false) + .includes(:unit) + .find_by(id: params[:id]) + peer_progress_not_found! if project.nil? + + unit = project.unit + task_definition = unit.task_definitions.find_by( + id: params[:task_definition_id] + ) + peer_progress_not_found! if task_definition.nil? + + released = task_definition.start_date.present? && + task_definition.start_date <= Time.zone.now + peer_progress_not_found! unless released + + target_grade = project.target_grade + if target_grade.present? && unit.grade_value?(target_grade) && + task_definition.target_grade > target_grade + peer_progress_not_found! + end + + header 'Cache-Control', 'private, no-store' + + present peer_progress_result( + project: project, + task_definition: task_definition + ), with: Grape::Presenters::Presenter + end +end diff --git a/db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb b/db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb new file mode 100644 index 0000000000..2d17007d0b --- /dev/null +++ b/db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddPeerProgressEnabledToUnits < ActiveRecord::Migration[8.0] + def change + add_column :units, + :peer_progress_enabled, + :boolean, + default: false, + null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index b1adfb5648..b62c6a5552 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_09_153000) do +ActiveRecord::Schema[8.0].define(version: 2026_08_10_033824) 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 @@ -915,6 +915,7 @@ t.integer "feedback_overflow_threshold_days", default: 7 t.boolean "enforce_feedback_before_discussed_in_class", default: false, null: false t.text "grade_values", size: :long, collation: "utf8mb4_bin" + t.boolean "peer_progress_enabled", default: false, null: false t.index ["draft_task_definition_id"], name: "index_units_on_draft_task_definition_id" t.index ["main_convenor_id"], name: "index_units_on_main_convenor_id" t.index ["overseer_image_id"], name: "index_units_on_overseer_image_id" From e224f2b2b4a9207f74e3ae42b7162b4c0ea044a1 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 15:30:15 +1000 Subject: [PATCH 025/247] test(ppi): cover endpoint privacy and authorisation --- test/api/peer_progress_api_test.rb | 393 +++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 test/api/peer_progress_api_test.rb diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb new file mode 100644 index 0000000000..d5a4b1fe6c --- /dev/null +++ b/test/api/peer_progress_api_test.rb @@ -0,0 +1,393 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PeerProgressApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + RESPONSE_KEYS = %w[ + task_definition_id + unit_id + target_grade + submitted_percentage + is_suppressed + is_stale + is_feature_enabled + last_updated_at + unavailable_message + ].freeze + + FORBIDDEN_KEYS = %w[ + cohort_size + submitted_count + user_id + student_id + username + first_name + last_name + project_id + task_status + marks + feedback + ].freeze + + setup do + clear_auth_header + + @original_minimum_cohort_size = ENV.fetch['DF_PPI_MINIMUM_COHORT_SIZE', nil] + @original_stale_after_hours = ENV.fetch['DF_PPI_STALE_AFTER_HOURS', nil] + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '5' + ENV['DF_PPI_STALE_AFTER_HOURS'] = '48' + + @unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 1, + staff_count: 0, + outcome_count: 0 + ) + @unit.update!(peer_progress_enabled: true) + + @student = create(:user, :student) + @project = @unit.enrol_student( + @student, + @unit.tutorials.first.campus + ) + @project.update!(target_grade: 1) + + @task_definition = create( + :task_definition, + unit: @unit, + target_grade: 0, + start_date: 1.day.ago, + outcome_count: 0 + ) + end + + teardown do + restore_env( + 'DF_PPI_MINIMUM_COHORT_SIZE', + @original_minimum_cohort_size + ) + restore_env( + 'DF_PPI_STALE_AFTER_HOURS', + @original_stale_after_hours + ) + clear_auth_header + end + + test 'requires authentication' do + get endpoint + + assert_equal 419, last_response.status + end + + test 'returns a privacy-safe normal response for the owning student' do + create_snapshot( + submitted_percentage: 62.5, + cohort_size: 5 + ) + + request_as(@student) + + assert_equal 200, last_response.status, last_response.body + body = last_response_body + + assert_json_limit_keys_to_exactly RESPONSE_KEYS, body + assert_equal @task_definition.id, body['task_definition_id'] + assert_equal @unit.id, body['unit_id'] + assert_equal @project.target_grade, body['target_grade'] + assert_in_delta 62.5, body['submitted_percentage'], 0.001 + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert body['last_updated_at'].present? + assert_equal '', body['unavailable_message'] + assert_empty FORBIDDEN_KEYS & body.keys + assert_includes last_response.headers['Cache-Control'], 'no-store' + end + + test 'returns a genuine zero as zero rather than unavailable' do + create_snapshot( + submitted_percentage: 0, + cohort_size: 5 + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + + assert_equal 0.0, body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal '', body['unavailable_message'] + end + + test 'does not allow a student to read another students project' do + other_student = create(:user, :student) + other_project = @unit.enrol_student( + other_student, + @unit.tutorials.first.campus + ) + other_project.update!(target_grade: 1) + + request_as( + @student, + endpoint(project: other_project) + ) + + assert_peer_progress_not_found + end + + test 'does not allow a tutor to use the student endpoint' do + tutor = create(:user, :tutor) + @unit.employ_staff(tutor, Role.tutor) + + request_as(tutor) + + assert_peer_progress_not_found + end + + test 'does not allow an unenrolled project' do + @project.update!(enrolled: false) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'does not allow an inactive unit in the first release' do + @unit.update!(active: false) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'does not allow a task from another unit' do + other_unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 + ) + other_task = create( + :task_definition, + unit: other_unit, + target_grade: 0, + start_date: 1.day.ago, + outcome_count: 0 + ) + + request_as( + @student, + endpoint(task_definition: other_task) + ) + + assert_peer_progress_not_found + end + + test 'does not allow a task above the students target grade' do + higher_grade_task = create( + :task_definition, + unit: @unit, + target_grade: 2, + start_date: 1.day.ago, + outcome_count: 0 + ) + + request_as( + @student, + endpoint(task_definition: higher_grade_task) + ) + + assert_peer_progress_not_found + end + + test 'does not allow an unreleased task' do + future_task = create( + :task_definition, + unit: @unit, + target_grade: 0, + start_date: 1.day.from_now, + outcome_count: 0 + ) + + request_as( + @student, + endpoint(task_definition: future_task) + ) + + assert_peer_progress_not_found + end + + test 'returns a neutral unavailable state when no snapshot exists' do + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + + assert_json_limit_keys_to_exactly RESPONSE_KEYS, body + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert body['unavailable_message'].present? + end + + test 'suppresses a cohort below the configured threshold' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 4 + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + + assert_nil body['submitted_percentage'] + assert_equal true, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert body['unavailable_message'].present? + assert_not body.key?('cohort_size') + end + + test 'shows a cohort at the exact configured threshold' do + create_snapshot( + submitted_percentage: 40, + cohort_size: 5 + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + + assert_equal 40.0, body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + end + + test 'hides the percentage when an active unit snapshot is stale' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 5, + calculated_at: 49.hours.ago + ) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal true, body['is_stale'] + assert body['last_updated_at'].present? + assert body['unavailable_message'].present? + end + + test 'returns a disabled state when the unit has disabled PPI' do + @unit.update!(peer_progress_enabled: false) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal false, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert body['unavailable_message'].present? + end + + test 'ignores a browser supplied target grade' do + create_snapshot( + submitted_percentage: 60, + cohort_size: 5 + ) + + request_as( + @student, + "#{endpoint}?target_grade=3" + ) + + assert_equal 200, last_response.status + body = last_response_body + + assert_equal @project.target_grade, body['target_grade'] + assert_equal 60.0, body['submitted_percentage'] + end + + test 'fails closed when required PPI configuration is missing' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 5 + ) + ENV.delete('DF_PPI_MINIMUM_COHORT_SIZE') + + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + end + + private + + def endpoint(project: @project, task_definition: @task_definition) + "/api/projects/#{project.id}/task_def_id/" \ + "#{task_definition.id}/peer_progress" + end + + def request_as(user, path = endpoint) + clear_auth_header + add_auth_header_for(user: user) + get path + end + + def create_snapshot( + submitted_percentage:, + cohort_size:, + calculated_at: Time.zone.now + ) + create( + :peer_progress_snapshot, + unit: @unit, + task_definition: @task_definition, + target_grade: @project.target_grade, + submitted_percentage: submitted_percentage, + cohort_size: cohort_size, + calculated_at: calculated_at + ) + end + + def assert_peer_progress_not_found + assert_equal 404, last_response.status + assert_equal( + PeerProgressApi::NOT_FOUND_MESSAGE, + last_response_body['error'] + ) + end + + def restore_env(name, value) + if value.nil? + ENV.delete(name) + else + ENV[name] = value + end + end +end From f0a3d34f4753bd481233d576ba27a95c00780c0a Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 15:30:22 +1000 Subject: [PATCH 026/247] docs(ppi): document peer progress API --- docs/peer-progress-api.md | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/peer-progress-api.md diff --git a/docs/peer-progress-api.md b/docs/peer-progress-api.md new file mode 100644 index 0000000000..7dd6fd6d9c --- /dev/null +++ b/docs/peer-progress-api.md @@ -0,0 +1,56 @@ +# Student Peer Progress API + +## Route + +`GET /api/projects/:id/task_def_id/:task_definition_id/peer_progress` + +The route is restricted to the authenticated student who owns the enrolled project. +The unit and target grade are derived from that project. The route does not accept a +student ID, unit ID, trimester, cohort, or target grade from the browser. + +## Response fields + +- `task_definition_id` +- `unit_id` +- `target_grade` +- `submitted_percentage` +- `is_suppressed` +- `is_stale` +- `is_feature_enabled` +- `last_updated_at` +- `unavailable_message` + +`submitted_percentage` is `null` for suppressed, stale, disabled, and unavailable +states. A genuine zero is returned as `0.0`. + +## Privacy boundary + +The response must not include names, usernames, student IDs, peer project IDs, +marks, feedback, individual task statuses, submitted counts, or raw cohort sizes. +The endpoint reads `cohort_size` only to apply suppression. + +## Configuration + +- `DF_PPI_MINIMUM_COHORT_SIZE`: approved minimum cohort size. +- `DF_PPI_STALE_AFTER_HOURS`: approved maximum snapshot age. + +Both must be positive integers. No production defaults are included. An enabled unit +with a valid snapshot fails closed with HTTP 503 when either value is missing or invalid. + +## Feature enablement + +`units.peer_progress_enabled` defaults to `false`. Enable it for a test unit only after +privacy thresholds and the endpoint have been reviewed. + +## Status behaviour + +- `200`: authorised request, including normal, zero, suppressed, stale, disabled, or unavailable state. +- `404`: wrong user, project, unit, task, target-grade applicability, inactive unit, or unreleased task. The same message is used to reduce object enumeration. +- `419`: OnTrack authentication failed. +- `503`: required PPI configuration is missing or invalid. + +## Handover + +The background job creates the snapshots. This endpoint only authorises the student, +selects the correct stored snapshot, applies display suppression and freshness rules, +and returns an allowlisted response. Frontend HTTP mapping remains in PPI-F01. From 75b9989abe92f2189a5ab9d5bea3a491a47b6caa Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 17:35:10 +1000 Subject: [PATCH 027/247] fix(ppi): finalise the student response contract --- app/api/peer_progress_api.rb | 20 ++- test/api/peer_progress_api_test.rb | 188 ++++++++++++++++++++++++++++- 2 files changed, 196 insertions(+), 12 deletions(-) diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb index 58d1c8b71d..d72c7e5abc 100644 --- a/app/api/peer_progress_api.rb +++ b/app/api/peer_progress_api.rb @@ -10,6 +10,7 @@ class PeerProgressApi < Grape::API CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.' before do + header 'Cache-Control', 'private, no-store' authenticated? end @@ -18,6 +19,15 @@ def peer_progress_not_found! error!({ error: PeerProgressApi::NOT_FOUND_MESSAGE }, 404) end + def safe_target_grade(project) + target_grade = project.target_grade + + return nil if target_grade.nil? + return nil unless project.unit.grade_value?(target_grade) + + target_grade + end + def positive_integer_env!(name) value = Integer(ENV.fetch(name), 10) raise ArgumentError unless value.positive? @@ -40,12 +50,12 @@ def peer_progress_payload( { task_definition_id: task_definition.id, unit_id: project.unit_id, - target_grade: project.target_grade, + target_grade: safe_target_grade(project), submitted_percentage: submitted_percentage, is_suppressed: is_suppressed, is_stale: is_stale, is_feature_enabled: is_feature_enabled, - last_updated_at: snapshot&.calculated_at&.iso8601, + last_updated_at: snapshot&.calculated_at&.utc&.iso8601, unavailable_message: unavailable_message } end @@ -62,8 +72,8 @@ def peer_progress_result(project:, task_definition:) ) end - target_grade = project.target_grade - unless target_grade.present? && unit.grade_value?(target_grade) + target_grade = safe_target_grade(project) + unless target_grade return peer_progress_payload( project: project, task_definition: task_definition, @@ -157,8 +167,6 @@ def peer_progress_result(project:, task_definition:) peer_progress_not_found! end - header 'Cache-Control', 'private, no-store' - present peer_progress_result( project: project, task_definition: task_definition diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index d5a4b1fe6c..550e3799bd 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'time' class PeerProgressApiTest < ActiveSupport::TestCase include Rack::Test::Methods @@ -36,8 +37,11 @@ class PeerProgressApiTest < ActiveSupport::TestCase setup do clear_auth_header - @original_minimum_cohort_size = ENV.fetch['DF_PPI_MINIMUM_COHORT_SIZE', nil] - @original_stale_after_hours = ENV.fetch['DF_PPI_STALE_AFTER_HOURS', nil] + @original_minimum_cohort_size = + ENV.fetch('DF_PPI_MINIMUM_COHORT_SIZE', nil) + + @original_stale_after_hours = + ENV.fetch('DF_PPI_STALE_AFTER_HOURS', nil) ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '5' ENV['DF_PPI_STALE_AFTER_HOURS'] = '48' @@ -84,6 +88,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase get endpoint assert_equal 419, last_response.status + assert_private_no_store end test 'returns a privacy-safe normal response for the owning student' do @@ -96,8 +101,8 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status, last_response.body body = last_response_body + assert_peer_progress_response_contract(body) - assert_json_limit_keys_to_exactly RESPONSE_KEYS, body assert_equal @task_definition.id, body['task_definition_id'] assert_equal @unit.id, body['unit_id'] assert_equal @project.target_grade, body['target_grade'] @@ -107,8 +112,6 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal true, body['is_feature_enabled'] assert body['last_updated_at'].present? assert_equal '', body['unavailable_message'] - assert_empty FORBIDDEN_KEYS & body.keys - assert_includes last_response.headers['Cache-Control'], 'no-store' end test 'returns a genuine zero as zero rather than unavailable' do @@ -121,6 +124,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status body = last_response_body + assert_peer_progress_response_contract(body) assert_equal 0.0, body['submitted_percentage'] assert_equal false, body['is_suppressed'] @@ -234,8 +238,8 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status body = last_response_body + assert_peer_progress_response_contract(body) - assert_json_limit_keys_to_exactly RESPONSE_KEYS, body assert_nil body['submitted_percentage'] assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] @@ -254,6 +258,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status body = last_response_body + assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] assert_equal true, body['is_suppressed'] @@ -272,6 +277,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status body = last_response_body + assert_peer_progress_response_contract(body) assert_equal 40.0, body['submitted_percentage'] assert_equal false, body['is_suppressed'] @@ -288,6 +294,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status body = last_response_body + assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] assert_equal false, body['is_suppressed'] @@ -303,6 +310,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status body = last_response_body + assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] assert_equal false, body['is_suppressed'] @@ -325,11 +333,116 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status body = last_response_body + assert_peer_progress_response_contract(body) assert_equal @project.target_grade, body['target_grade'] assert_equal 60.0, body['submitted_percentage'] end + test 'returns a neutral unavailable state when no target grade is selected' do + @project.update_column(:target_grade, nil) + + request_as(@student) + + assert_equal 200, last_response.status + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['target_grade'] + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert_equal( + PeerProgressApi::UNAVAILABLE_MESSAGE, + body['unavailable_message'] + ) + end + + test 'does not expose an invalid stored target grade' do + @project.update_column(:target_grade, 999) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['target_grade'] + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert_nil body['last_updated_at'] + assert_equal( + PeerProgressApi::UNAVAILABLE_MESSAGE, + body['unavailable_message'] + ) + end + + test 'returns unavailable rather than zero for an empty stored cohort' do + create_snapshot( + submitted_percentage: nil, + cohort_size: 0 + ) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_nil body['submitted_percentage'] + assert_equal false, body['is_suppressed'] + assert_equal false, body['is_stale'] + assert_equal true, body['is_feature_enabled'] + assert body['last_updated_at'].present? + assert body['unavailable_message'].present? + end + + test 'returns the snapshot timestamp in UTC ISO 8601 format' do + calculated_at = Time.zone.parse('2026-08-10 03:15:00 UTC') + + create_snapshot( + submitted_percentage: 62.5, + cohort_size: 5, + calculated_at: calculated_at + ) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal( + calculated_at.utc.iso8601, + body['last_updated_at'] + ) + end + + test 'fails closed when the stale window configuration is missing' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 5 + ) + + ENV.delete('DF_PPI_STALE_AFTER_HOURS') + + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + assert_private_no_store + end + test 'fails closed when required PPI configuration is missing' do create_snapshot( submitted_percentage: 50, @@ -344,6 +457,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase PeerProgressApi::CONFIG_ERROR_MESSAGE, last_response_body['error'] ) + assert_private_no_store end private @@ -381,6 +495,7 @@ def assert_peer_progress_not_found PeerProgressApi::NOT_FOUND_MESSAGE, last_response_body['error'] ) + assert_private_no_store end def restore_env(name, value) @@ -390,4 +505,65 @@ def restore_env(name, value) ENV[name] = value end end + + def assert_private_no_store + cache_control = last_response.headers.fetch('Cache-Control', '') + + assert_includes cache_control, 'private' + assert_includes cache_control, 'no-store' + end + + def assert_peer_progress_response_contract(body) + assert_json_limit_keys_to_exactly RESPONSE_KEYS, body + + assert_kind_of Integer, body['task_definition_id'] + assert_kind_of Integer, body['unit_id'] + + assert( + body['target_grade'].nil? || + body['target_grade'].is_a?(Integer), + 'target_grade must be an integer or null' + ) + + assert( + body['submitted_percentage'].nil? || + body['submitted_percentage'].is_a?(Numeric), + 'submitted_percentage must be numeric or null' + ) + + unless body['submitted_percentage'].nil? + assert_operator body['submitted_percentage'], :>=, 0.0 + assert_operator body['submitted_percentage'], :<=, 100.0 + end + + %w[ + is_suppressed + is_stale + is_feature_enabled + ].each do |key| + assert_includes( + [true, false], + body.fetch(key), + "#{key} must be a boolean" + ) + end + + unless body['last_updated_at'].nil? + parsed_timestamp = nil + + assert_nothing_raised do + parsed_timestamp = Time.iso8601(body['last_updated_at']) + end + + assert_equal( + 0, + parsed_timestamp.utc_offset, + 'last_updated_at must use UTC' + ) + end + + assert_kind_of String, body['unavailable_message'] + assert_empty FORBIDDEN_KEYS & body.keys + assert_private_no_store + end end From e80b47101ac1ae64d79201ca429a67b6a8e04188 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 10 Aug 2026 17:35:19 +1000 Subject: [PATCH 028/247] docs(ppi): document peer progress response states --- docs/peer-progress-api.md | 153 +++++++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 12 deletions(-) diff --git a/docs/peer-progress-api.md b/docs/peer-progress-api.md index 7dd6fd6d9c..a4d92422c6 100644 --- a/docs/peer-progress-api.md +++ b/docs/peer-progress-api.md @@ -10,18 +10,147 @@ student ID, unit ID, trimester, cohort, or target grade from the browser. ## Response fields -- `task_definition_id` -- `unit_id` -- `target_grade` -- `submitted_percentage` -- `is_suppressed` -- `is_stale` -- `is_feature_enabled` -- `last_updated_at` -- `unavailable_message` - -`submitted_percentage` is `null` for suppressed, stale, disabled, and unavailable -states. A genuine zero is returned as `0.0`. +## Successful response contract + +All authorised business states return HTTP 200 with exactly the following +fields. The API uses snake_case. PPI-F01 maps these fields to the frontend +camelCase interface. + +| Field | Type | Nullable | Meaning | +| --- | --- | --- | --- | +| `task_definition_id` | Integer | No | Requested task definition | +| `unit_id` | Integer | No | Unit derived from the authenticated student's project | +| `target_grade` | Integer | Yes | Valid server-side target grade, or `null` when none is valid | +| `submitted_percentage` | Number | Yes | Value from 0.0 to 100.0, or `null` when the value must not be displayed | +| `is_suppressed` | Boolean | No | True when the cohort is below the privacy threshold | +| `is_stale` | Boolean | No | True when the stored snapshot is older than the approved freshness window | +| `is_feature_enabled` | Boolean | No | Whether the unit has enabled PPI | +| `last_updated_at` | String | Yes | UTC ISO 8601 snapshot time, or `null` when no snapshot was used | +| `unavailable_message` | String | No | Empty on success; otherwise a neutral and privacy-safe message | + +A genuine zero is returned as `0.0`. It is not treated as missing data. + +`submitted_percentage` must be `null` for suppressed, stale, disabled and +unavailable states. The response never includes raw cohort size or submitted +count. + +## State behaviour + +| State | Percentage | Suppressed | Stale | Enabled | Last updated | +| --- | --- | --- | --- | --- | --- | +| Normal | Number | False | False | True | Timestamp | +| Genuine zero | `0.0` | False | False | True | Timestamp | +| Small cohort | `null` | True | False | True | Timestamp | +| Stale snapshot | `null` | False | True | True | Timestamp | +| No snapshot | `null` | False | False | True | `null` | +| No valid target grade | `null` | False | False | True | `null` | +| Feature disabled | `null` | False | False | False | `null` | + +### Normal +``` json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": 62.5, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": "2026-08-10T03:15:00Z", + "unavailable_message": "" +} +``` + +### Genuine zero +``` json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": 0.0, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": "2026-08-10T03:15:00Z", + "unavailable_message": "" +} +``` + +### Small-cohort suppression +``` json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": null, + "is_suppressed": true, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": "2026-08-10T03:15:00Z", + "unavailable_message": "Peer progress is currently unavailable." +} +``` + +### Stale data +``` json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": null, + "is_suppressed": false, + "is_stale": true, + "is_feature_enabled": true, + "last_updated_at": "2026-08-07T03:15:00Z", + "unavailable_message": "Peer progress is currently unavailable." +} +``` + +### No target grade or no snapshot +``` json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": null, + "submitted_percentage": null, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": null, + "unavailable_message": "Peer progress is currently unavailable." +} +``` + +### Disabled +``` json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": null, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": false, + "last_updated_at": null, + "unavailable_message": "Peer progress is currently unavailable." +} +``` + +## Error responses + +Business states such as suppressed, stale, disabled and missing data return the +normal nine-field HTTP 200 response. + +Access-control and technical failures use a separate error response: + +```json +{ + "error": "Safe error message" +} +``` +- `404`: the student cannot safely access the requested project or task. +- `419`: authentication failed through the existing OnTrack authentication flow. +- `503`: required PPI configuration is missing or invalid. ## Privacy boundary From 342d1c2bd7759a6eea63f5c2effe860b25d84086 Mon Sep 17 00:00:00 2001 From: Swyam Khare Date: Tue, 11 Aug 2026 02:16:43 +1000 Subject: [PATCH 029/247] docs(notifications): record EN-E02 review notes; test bulk path Address review feedback (no behaviour change): - Note the recipient == by_user guard is belt-and-braces (unreachable once role == :tutor, kept to survive future user_role changes). - Document known limitations in the event doc: bulk marking sends one inline email per task (bulk: flag ignored on purpose; latent path), the recursive_fix cascade raises task_comment_created rather than this event, and site admins on the student-side branches notify nobody by design. - Add a bulk-path test; note the group-task fan-out as a follow-up. --- app/models/task.rb | 3 ++ .../events/task_status_changed.md | 33 +++++++++++++++++-- test/models/notification_task_status_test.rb | 11 +++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/app/models/task.rb b/app/models/task.rb index 34738cf065..fce5188d25 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -704,6 +704,9 @@ def notify_student_of_status_change(by_user, role, previous_status_id) return if task_status_id == previous_status_id recipient = project&.student + # recipient == by_user is belt and braces: once role == :tutor the actor + # cannot be the student, since user_role checks user == student first. Kept + # so a future change to user_role cannot start emailing someone themselves. return if recipient.blank? || recipient == by_user NotificationService.notify( diff --git a/docs/notifications/events/task_status_changed.md b/docs/notifications/events/task_status_changed.md index 4525ed6d56..58be8c8b2c 100644 --- a/docs/notifications/events/task_status_changed.md +++ b/docs/notifications/events/task_status_changed.md @@ -68,11 +68,40 @@ event when it exists. Adding this event never required editing the mailer. 4. Turn that student's task notifications off in their profile, have the tutor mark again, and no email arrives. +## Known limitations and deliberate choices + +- **Bulk marking sends one email per task, inline.** `Project#trigger_week_end` + (`app/models/project.rb`) loops `trigger_transition(trigger: 'complete', + bulk: true)` over a student's discuss/demonstrate tasks, and this event ignores + the `bulk:` flag, so a single request can send several near-identical emails + (NotificationService delivers inline by design). The path is latent today — no + `doubtfire-web` caller drives `trigger_week_end`. Left un-suppressed on purpose + so bulk-marked tasks still notify; batching many into one email belongs with the + queue work (EN-F03), not here. + +- **The fix-and-resubmit cascade does not raise this event.** Inside `assess`, the + `recursive_fix` cascade calls `assess` directly on dependent tasks instead of + going through `trigger_transition`, so those status changes raise nothing here. + The student is still emailed, but via `task_comment_created` (the cascade adds an + automated comment) — i.e. under a different event. Reconciling that is out of + scope for EN-E02. + +- **Site admins acting on the student-side branches notify nobody.** `user_role` + returns `:admin` for an unenrolled site admin, who can still drive the + `working_on_it` / `need_help` / `not_started` / `ready_for_feedback` branches. + The `role == :tutor` guard means those changes notify no one. That is intended: + an admin poking at a task is not a tutor marking it for the student. + ## Tests `test/models/notification_task_status_test.rb` Covers the staff change, the student's own action sending nothing, an unchanged status sending nothing, the preference switch, the status value staying out of -the email, the event-specific template being used, and that a notification -failure still leaves the transition committed. +the email, the event-specific template being used, a bulk mark still notifying, +and that a notification failure still leaves the transition committed. + +Not yet covered: the group-task fan-out (each member emailed about their own +task). The behaviour is correct — `propagate_transition` threads `by_user` +through a per-member `trigger_transition` — but a factory-built group task test +is a worthwhile follow-up. diff --git a/test/models/notification_task_status_test.rb b/test/models/notification_task_status_test.rb index f6fd9c8d28..aa738fed2b 100644 --- a/test/models/notification_task_status_test.rb +++ b/test/models/notification_task_status_test.rb @@ -114,6 +114,17 @@ def test_the_event_specific_template_is_used_instead_of_the_generic_one assert_includes body, 'The new status is not included in this email' end + def test_bulk_marking_still_notifies_one_per_task + # This event ignores the bulk: flag on purpose (see the event doc): a bulk + # mark still notifies. One call, one task, one email. + assert_difference 'Notification.count', 1 do + assert @task.trigger_transition(trigger: 'discuss', by_user: @tutor, bulk: true) + end + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + def test_a_notification_failure_does_not_stop_the_transition result = nil From df1f2b9a403872f233df39eb01268525238047b4 Mon Sep 17 00:00:00 2001 From: Swyam Khare Date: Tue, 11 Aug 2026 02:48:17 +1000 Subject: [PATCH 030/247] test(notifications): cover notifications api endpoints EN-T02. Adds test/api/notifications_api_test.rb covering all five routes in app/api/notifications_api.rb: - GET /api/notifications (list, newest first, own only) - GET /api/notifications (unread_only filter) - GET /api/notifications/unread_count (counts only the user's unread) - PUT /api/notifications/:id/read (marks read) - PUT /api/notifications/read_all (clears only the user's unread) - DELETE /api/notifications/:id (removes it) Plus the cross-user case: a user cannot mark or delete another user's notification (404, scoped through current_user.notifications), the list never leaks other users' notifications, and an unauthenticated request is rejected (419). Uses the notification factory and mirrors push_subscriptions_api_test.rb. --- test/api/notifications_api_test.rb | 156 +++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 test/api/notifications_api_test.rb diff --git a/test/api/notifications_api_test.rb b/test/api/notifications_api_test.rb new file mode 100644 index 0000000000..b39403f737 --- /dev/null +++ b/test/api/notifications_api_test.rb @@ -0,0 +1,156 @@ +require 'test_helper' + +# EN-T02: the notifications API endpoints in app/api/notifications_api.rb. +# +# Five routes: +# GET /api/notifications (list, optional unread_only) +# GET /api/notifications/unread_count +# PUT /api/notifications/:id/read +# PUT /api/notifications/read_all +# DELETE /api/notifications/:id +# +# Every route scopes through current_user.notifications, so one user can never +# touch another's notifications. That is the case worth proving. +# +# Run this file on its own, not the whole suite: the test database is the +# development database (DF_TEST_DB_DATABASE == doubtfire-dev), so a full run +# holds locks and rewrites seeded data. See item 11 in +# doubtfire-deploy/RUNNING-LOCALLY.md. +class NotificationsApiTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + @user = FactoryBot.create(:user, :student) + @other = FactoryBot.create(:user, :student) + end + + # GET /api/notifications ---------------------------------------------------- + + def test_list_returns_the_users_notifications_newest_first + older = FactoryBot.create(:notification, user: @user, created_at: 2.days.ago) + newer = FactoryBot.create(:notification, user: @user, created_at: 1.hour.ago) + FactoryBot.create(:notification, user: @other) # must not appear + + add_auth_header_for(user: @user) + get '/api/notifications' + + assert_equal 200, last_response.status + + json = JSON.parse(last_response.body) + ids = json.map { |n| n['id'] } + + assert_equal 2, json.length, 'only the current user\'s notifications' + assert_equal [newer.id, older.id], ids, 'recent_first order' + end + + def test_list_unread_only_filters_out_read_notifications + unread = FactoryBot.create(:notification, user: @user) + FactoryBot.create(:notification, :read, user: @user) + + add_auth_header_for(user: @user) + get '/api/notifications', unread_only: true + + assert_equal 200, last_response.status + + json = JSON.parse(last_response.body) + + assert_equal 1, json.length + assert_equal unread.id, json.first['id'] + end + + # GET /api/notifications/unread_count --------------------------------------- + + def test_unread_count_counts_only_the_users_unread + FactoryBot.create_list(:notification, 2, user: @user) # unread + FactoryBot.create(:notification, :read, user: @user) # read, excluded + FactoryBot.create(:notification, user: @other) # other user, excluded + + add_auth_header_for(user: @user) + get '/api/notifications/unread_count' + + assert_equal 200, last_response.status + assert_equal 2, JSON.parse(last_response.body)['count'] + end + + # PUT /api/notifications/:id/read ------------------------------------------- + + def test_marking_a_notification_as_read + notification = FactoryBot.create(:notification, user: @user) + + add_auth_header_for(user: @user) + put "/api/notifications/#{notification.id}/read" + + assert_equal 200, last_response.status + assert_not_nil JSON.parse(last_response.body)['read_at'] + assert_not_nil notification.reload.read_at + end + + # PUT /api/notifications/read_all ------------------------------------------- + + def test_marking_all_as_read_clears_only_the_users_unread + FactoryBot.create_list(:notification, 3, user: @user) + others = FactoryBot.create(:notification, user: @other) + + add_auth_header_for(user: @user) + put '/api/notifications/read_all' + + assert_equal 200, last_response.status + assert JSON.parse(last_response.body)['success'] + assert_equal 0, @user.notifications.unread.count + assert_nil others.reload.read_at, 'another user\'s notifications are untouched' + end + + # DELETE /api/notifications/:id --------------------------------------------- + + def test_deleting_a_notification + notification = FactoryBot.create(:notification, user: @user) + + add_auth_header_for(user: @user) + + assert_difference 'Notification.count', -1 do + delete "/api/notifications/#{notification.id}" + end + + assert_equal 200, last_response.status + end + + # Cross-user isolation ------------------------------------------------------ + + def test_a_user_cannot_mark_another_users_notification_as_read + theirs = FactoryBot.create(:notification, user: @other) + + add_auth_header_for(user: @user) + put "/api/notifications/#{theirs.id}/read" + + assert_equal 404, last_response.status + assert_nil theirs.reload.read_at, 'it must stay unread' + end + + def test_a_user_cannot_delete_another_users_notification + theirs = FactoryBot.create(:notification, user: @other) + + add_auth_header_for(user: @user) + + assert_no_difference 'Notification.count' do + delete "/api/notifications/#{theirs.id}" + end + + assert_equal 404, last_response.status + end + + # Authentication ------------------------------------------------------------ + + def test_an_unauthenticated_request_is_rejected + clear_auth_header + + get '/api/notifications' + + assert_equal 419, last_response.status + end +end From 8dd48cf277f367ed071a1f3fab56683f723ba413 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Tue, 11 Aug 2026 09:30:56 +1000 Subject: [PATCH 031/247] test(ppi): complete coverage and lint cleanup --- app/sidekiq/aggregate_peer_progress_job.rb | 8 +- test/api/peer_progress_api_test.rb | 88 ++++++++++++++++++- .../peer_progress_aggregation_service_test.rb | 47 +++++++--- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/app/sidekiq/aggregate_peer_progress_job.rb b/app/sidekiq/aggregate_peer_progress_job.rb index 98b220f196..01e4fdb783 100644 --- a/app/sidekiq/aggregate_peer_progress_job.rb +++ b/app/sidekiq/aggregate_peer_progress_job.rb @@ -20,11 +20,11 @@ def perform(unit_id = nil) calculated_at = Time.zone.now if unit_id.present? - aggregate_unit(Unit.find(unit_id), calculated_at) + aggregate_unit(Unit.find(unit_id), calculated_at) else - Unit.active_units.find_each do |unit| - aggregate_unit(unit, calculated_at) - end + Unit.active_units.find_each do |unit| + aggregate_unit(unit, calculated_at) + end end at(1) diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index 550e3799bd..ee66672329 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -340,7 +340,11 @@ class PeerProgressApiTest < ActiveSupport::TestCase end test 'returns a neutral unavailable state when no target grade is selected' do + # Intentionally bypass validations and callbacks to verify that the API + # safely handles a project with no stored target grade. + # rubocop:disable Rails/SkipsModelValidations @project.update_column(:target_grade, nil) + # rubocop:enable Rails/SkipsModelValidations request_as(@student) @@ -361,7 +365,11 @@ class PeerProgressApiTest < ActiveSupport::TestCase end test 'does not expose an invalid stored target grade' do + # Intentionally bypass validations and callbacks to verify that the API + # safely handles an invalid legacy target-grade value. + # rubocop:disable Rails/SkipsModelValidations @project.update_column(:target_grade, 999) + # rubocop:enable Rails/SkipsModelValidations request_as(@student) @@ -443,6 +451,78 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_private_no_store end + test 'returns the same generic response for unknown project and task ids' do + unknown_project_id = Project.maximum(:id).to_i + 10_000 + + request_as( + @student, + "/api/projects/#{unknown_project_id}/task_def_id/" \ + "#{@task_definition.id}/peer_progress" + ) + + assert_peer_progress_not_found + + unknown_task_id = TaskDefinition.maximum(:id).to_i + 10_000 + + request_as( + @student, + "/api/projects/#{@project.id}/task_def_id/" \ + "#{unknown_task_id}/peer_progress" + ) + + assert_peer_progress_not_found + end + + test 'fails closed for invalid positive integer configuration' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 5 + ) + + [ + ['DF_PPI_MINIMUM_COHORT_SIZE', '0'], + ['DF_PPI_MINIMUM_COHORT_SIZE', 'not-a-number'], + ['DF_PPI_STALE_AFTER_HOURS', '-1'], + ['DF_PPI_STALE_AFTER_HOURS', '1.5'] + ].each do |name, value| + original = ENV.fetch(name, nil) + + begin + ENV[name] = value + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + assert_private_no_store + ensure + restore_env(name, original) + end + end + end + + test 'keeps a snapshot available at the exact stale boundary' do + travel_to Time.zone.parse('2026-08-10 12:00:00 UTC') do + create_snapshot( + submitted_percentage: 50, + cohort_size: 5, + calculated_at: 48.hours.ago + ) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal 50.0, body['submitted_percentage'] + assert_equal false, body['is_stale'] + end + end + test 'fails closed when required PPI configuration is missing' do create_snapshot( submitted_percentage: 50, @@ -491,10 +571,16 @@ def create_snapshot( def assert_peer_progress_not_found assert_equal 404, last_response.status + + body = last_response_body + + assert_json_limit_keys_to_exactly %w[error], body + assert_equal( PeerProgressApi::NOT_FOUND_MESSAGE, - last_response_body['error'] + body['error'] ) + assert_private_no_store end diff --git a/test/services/peer_progress_aggregation_service_test.rb b/test/services/peer_progress_aggregation_service_test.rb index 23cd2302ac..ab4236151b 100644 --- a/test/services/peer_progress_aggregation_service_test.rb +++ b/test/services/peer_progress_aggregation_service_test.rb @@ -192,8 +192,8 @@ def test_counts_uploads_regardless_of_the_current_task_status assert_equal 100.0, snapshot.submitted_percentage.to_f end - def test_does_not_count_a_task_without_a_submission_date - projects = create_list( + def test_does_not_mix_projects_or_submissions_from_another_unit + local_projects = create_list( :project, 2, unit: @unit, @@ -202,18 +202,42 @@ def test_does_not_count_a_task_without_a_submission_date ) create_submitted_task( - project: projects.first, + project: local_projects.first, task_definition: @pass_task ) - create( - :task, - project: projects.second, - task_definition: @pass_task, - task_status: TaskStatus.complete, - submission_date: nil + other_unit = create( + :unit, + with_students: false, + task_count: 0, + stream_count: 0, + tutorials: 0, + staff_count: 0, + outcome_count: 0 ) + other_task = create( + :task_definition, + unit: other_unit, + target_grade: 0, + outcome_count: 0 + ) + + other_projects = create_list( + :project, + 4, + unit: other_unit, + target_grade: 0, + enrolled: true + ) + + other_projects.each do |project| + create_submitted_task( + project: project, + task_definition: other_task + ) + end + run_service snapshot = find_snapshot( @@ -223,6 +247,7 @@ def test_does_not_count_a_task_without_a_submission_date assert_equal 2, snapshot.cohort_size assert_equal 50.0, snapshot.submitted_percentage.to_f + assert_not PeerProgressSnapshot.exists?(unit: other_unit) end def test_does_not_create_missing_task_rows @@ -295,8 +320,6 @@ def test_rounds_percentages_to_two_decimal_places assert_equal 33.33, snapshot.submitted_percentage.to_f end - private - def run_service PeerProgressAggregationService.call( unit: @unit, @@ -325,4 +348,4 @@ def create_submitted_task( submission_date: @calculated_at - 1.hour ) end -end \ No newline at end of file +end From a1548d86b080765f5cf46e68f05e1c0f75eede3c Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Tue, 11 Aug 2026 09:33:22 +1000 Subject: [PATCH 032/247] test(ppi): complete coverage and lint cleanup (remaining) --- .../aggregate_peer_progress_job_test.rb | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/sidekiq/aggregate_peer_progress_job_test.rb b/test/sidekiq/aggregate_peer_progress_job_test.rb index 31ce3c0304..e46b6fc56f 100644 --- a/test/sidekiq/aggregate_peer_progress_job_test.rb +++ b/test/sidekiq/aggregate_peer_progress_job_test.rb @@ -109,6 +109,45 @@ def test_enqueues_only_the_unit_id assert_equal [@active_unit.id], queued_job['args'] end + def test_creates_a_snapshot_through_the_real_aggregation_service + task_definition = create( + :task_definition, + unit: @active_unit, + target_grade: 0, + outcome_count: 0 + ) + + projects = create_list( + :project, + 2, + unit: @active_unit, + target_grade: 0, + enrolled: true + ) + + create( + :task, + project: projects.first, + task_definition: task_definition, + task_status: TaskStatus.ready_for_feedback, + submission_date: @calculated_at - 1.hour + ) + + travel_to @calculated_at do + AggregatePeerProgressJob.new.perform(@active_unit.id) + end + + snapshot = PeerProgressSnapshot.find_by!( + unit: @active_unit, + task_definition: task_definition, + target_grade: 0 + ) + + assert_equal 2, snapshot.cohort_size + assert_equal 50.0, snapshot.submitted_percentage.to_f + assert_equal @calculated_at, snapshot.calculated_at + end + private def create_minimal_unit(active:) From d47a4151043c23a7cd5d0542240f1c0ce678dffa Mon Sep 17 00:00:00 2001 From: Freddy Date: Tue, 11 Aug 2026 14:31:50 +1000 Subject: [PATCH 033/247] feat(notifications): email group membership changes --- app/models/group.rb | 39 +++++- .../group_membership_changed.html.erb | 12 ++ .../group_membership_changed.text.erb | 7 + .../events/group_membership_changed.md | 53 ++++++++ test/models/notification_group_test.rb | 121 ++++++++++++++++++ 5 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 app/views/notifications_mailer/group_membership_changed.html.erb create mode 100644 app/views/notifications_mailer/group_membership_changed.text.erb create mode 100644 docs/notifications/events/group_membership_changed.md create mode 100644 test/models/notification_group_test.rb diff --git a/app/models/group.rb b/app/models/group.rb index fec42947a6..3aa9cb36c6 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -135,24 +135,27 @@ def switch_to_tutorial tutorial if group_set.keep_groups_in_same_class && has_active_group_members? projects.each do |proj| - # We need to remove members to break the circular dependency and switch tutorial - remove_member(proj) + # These membership changes are temporary while moving tutorial, + # so they must not generate leave/join notifications. + remove_member(proj, notify: false) te = proj.enrol_in tutorial unless te.valid? raise "Unable to move group as #{proj.student.name} could not switch tutorial." end - add_member(proj) + add_member(proj, notify: false) end end self.save! end end - def add_member(project) + def add_member(project, notify: true) gm = project.group_membership_for_groupset(group_set) + membership_changed = gm.nil? || !gm.active? || gm.group_id != id + if gm.nil? gm = GroupMembership.create(group: self, project: project) group_memberships << gm @@ -164,16 +167,40 @@ def add_member(project) gm.active = true gm.save! + notify_group_membership_change(project, 'added to') if notify && membership_changed + gm end - def remove_member(project) + def remove_member(project, notify: true) gm = group_memberships.where(project: project).first + was_active = gm.active? + gm.active = false - gm.save + saved = gm.save + + notify_group_membership_change(project, 'removed from') if notify && saved && was_active + self end + def notify_group_membership_change(project, change) + student = project.student + return if student.blank? + + NotificationService.notify( + user: student, + type: 'general', + event: 'group_membership_changed', + message: "You have been #{change} group #{name} in #{unit.code}." + ) + rescue StandardError => e + logger.error( + "Failed to raise group_membership_changed notification for project #{project.id}: #{e.message}" + ) + end + + private :notify_group_membership_change # # check if the project is the same as the current submission # diff --git a/app/views/notifications_mailer/group_membership_changed.html.erb b/app/views/notifications_mailer/group_membership_changed.html.erb new file mode 100644 index 0000000000..0d3a3cbf14 --- /dev/null +++ b/app/views/notifications_mailer/group_membership_changed.html.erb @@ -0,0 +1,12 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ This notification was sent because your group membership changed in + <%= @doubtfire_product_name %>. +

+ +

+ You can view your current group information in <%= @doubtfire_product_name %>. +

\ No newline at end of file diff --git a/app/views/notifications_mailer/group_membership_changed.text.erb b/app/views/notifications_mailer/group_membership_changed.text.erb new file mode 100644 index 0000000000..42f05684c5 --- /dev/null +++ b/app/views/notifications_mailer/group_membership_changed.text.erb @@ -0,0 +1,7 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +This notification was sent because your group membership changed in <%= @doubtfire_product_name %>. + +You can view your current group information in <%= @doubtfire_product_name %>. \ No newline at end of file diff --git a/docs/notifications/events/group_membership_changed.md b/docs/notifications/events/group_membership_changed.md new file mode 100644 index 0000000000..791467f9ac --- /dev/null +++ b/docs/notifications/events/group_membership_changed.md @@ -0,0 +1,53 @@ +# Event: group_membership_changed + +| Field | Value | +|---|---| +| Event name | `group_membership_changed` | +| Category | `general` | +| What triggers it | A student's real group membership changes through `Group#add_member` or `Group#remove_member`. Internal remove/add operations performed by `Group#switch_to_tutorial` must not trigger this event. | +| Who receives it | Only the student whose membership changed (`project.student`). Other members of the group are not notified. This recipient scope was confirmed with the Email Notifications lead. | +| Preference that gates it | none, always sent | +| Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | +| Email body summary | Tells the affected student that they were added to or removed from a group. The membership change is not broadcast to other group members. Templates are `app/views/notifications_mailer/group_membership_changed.text.erb` and `group_membership_changed.html.erb` | +| Where it is raised | `app/models/group.rb:154` in `Group#add_member` and `app/models/group.rb:175` in `Group#remove_member`. Both call the private `notify_group_membership_change` helper at line 187 | + +## Recipient scope + +The agreed scope is **student only**. + +Only the student who was added to or removed from the group receives the notification. Other group members are not notified because a removal should not be broadcast to the group, and notifying the whole group would multiply the number of sends for a single membership change. + +## Tutorial switch guard + +`Group#switch_to_tutorial` temporarily removes and re-adds members while moving the group to another tutorial. These internal membership operations do not represent a real group membership change and must not send a leave-then-join notification pair. + +## Implementation + +The event uses: + +- `type: 'general'` +- `event: 'group_membership_changed'` +- recipient: `project.student` + +Event-specific HTML and text email templates are provided under `app/views/notifications_mailer/`. + +## How to check it by hand + +1. Use a unit with Group Work enabled and an existing student project. +2. Add the student to a group. +3. Open Mailpit at `http://localhost:8025` and confirm that one email is sent to the affected student. +4. Remove the same student from the group and confirm that one removal email is sent. +5. Confirm that no other group members receive the notification. +6. Move the group to another tutorial and confirm that the temporary remove/add operations do not create a leave-then-join email pair. + +## Tests + +`test/models/notification_group_test.rb` + +The tests cover: + +- adding a member sends one notification to the affected student +- removing a member sends one notification to the affected student +- other group members are not notified +- `switch_to_tutorial` does not send a leave-then-join notification pair +- a notification failure does not stop the membership change \ No newline at end of file diff --git a/test/models/notification_group_test.rb b/test/models/notification_group_test.rb new file mode 100644 index 0000000000..5849b3bfac --- /dev/null +++ b/test/models/notification_group_test.rb @@ -0,0 +1,121 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-V05: notify only the affected student when group membership changes. +class NotificationGroupTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + + @project = FactoryBot.create(:project) + @group = FactoryBot.create(:group, unit: @project.unit) + @student = @project.student + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_adding_a_member_notifies_only_that_student + assert_difference 'Notification.count', 1 do + @group.add_member(@project) + end + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'general', notification.notification_type + assert_equal 'group_membership_changed', notification.event + assert_includes notification.message, 'added to' + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + + # Confirms the event-specific template is being used. + assert_includes delivered_body, 'group membership changed' + end + + def test_removing_a_member_notifies_that_student + @group.add_member(@project) + + ActionMailer::Base.deliveries.clear + + assert_difference 'Notification.count', 1 do + @group.remove_member(@project) + end + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'general', notification.notification_type + assert_equal 'group_membership_changed', notification.event + assert_includes notification.message, 'removed from' + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_other_group_members_are_not_notified + other_project = FactoryBot.create(:project, unit: @project.unit) + + @group.add_member(other_project) + + ActionMailer::Base.deliveries.clear + + assert_difference 'Notification.count', 1 do + @group.add_member(@project) + end + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_not_equal other_project.student, notification.user + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + end + + def test_switch_to_tutorial_does_not_send_leave_then_join_notifications + unit = FactoryBot.create( + :unit, + group_sets: 1, + groups: [{ gs: 0, students: 0 }] + ) + + group_set = unit.group_sets.first + group_set.update!( + keep_groups_in_same_class: true, + allow_students_to_manage_groups: true + ) + + group = group_set.groups.first + + project_one = group.tutorial.projects.first + project_two = group.tutorial.projects.last + + group.add_member(project_one) + group.add_member(project_two) + + new_tutorial = FactoryBot.create(:tutorial, unit: unit, campus: nil) + + ActionMailer::Base.deliveries.clear + + assert_no_difference 'Notification.count' do + group.switch_to_tutorial(new_tutorial) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_notification_failure_does_not_stop_membership_change + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification failed' } do + assert_nothing_raised do + @group.add_member(@project) + end + end + + assert_includes @group.reload.projects, @project + end +end From 9991c99fea2ad2d72a96c8bd172e96c33ac474c9 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Tue, 11 Aug 2026 17:00:12 +1000 Subject: [PATCH 034/247] fix(ppi): count verified student uploads --- .../peer_progress_aggregation_service.rb | 2 +- .../peer_progress_aggregation_service_test.rb | 103 +++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/app/services/peer_progress_aggregation_service.rb b/app/services/peer_progress_aggregation_service.rb index 30bbf16d7a..d4c053add2 100644 --- a/app/services/peer_progress_aggregation_service.rb +++ b/app/services/peer_progress_aggregation_service.rb @@ -77,7 +77,7 @@ def submitted_counts_for(cohort:, task_definitions:) project_id: cohort.select(:id), task_definition_id: task_definitions.select(:id) ) - .where.not(submission_date: nil) + .where.not(file_uploaded_at: nil) .group(:task_definition_id) .distinct .count(:project_id) diff --git a/test/services/peer_progress_aggregation_service_test.rb b/test/services/peer_progress_aggregation_service_test.rb index ab4236151b..83e7364c25 100644 --- a/test/services/peer_progress_aggregation_service_test.rb +++ b/test/services/peer_progress_aggregation_service_test.rb @@ -320,6 +320,104 @@ def test_rounds_percentages_to_two_decimal_places assert_equal 33.33, snapshot.submitted_percentage.to_f end + def test_does_not_count_staff_assessment_without_a_student_upload + project = create( + :project, + unit: @unit, + target_grade: 0, + enrolled: true + ) + + create( + :task, + project: project, + task_definition: @pass_task, + task_status: TaskStatus.complete, + file_uploaded_at: nil, + submission_date: @calculated_at - 1.hour, + assessment_date: @calculated_at - 1.hour + ) + + run_service + + snapshot = find_snapshot( + task_definition: @pass_task, + target_grade: 0 + ) + + assert_equal 1, snapshot.cohort_size + assert_equal 0.0, snapshot.submitted_percentage.to_f + end + + def test_counts_a_group_upload_for_each_participating_project + group_unit = create( + :unit, + with_students: true, + student_count: 2, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + task_count: 0, + tutorials: 1, + group_sets: 1, + groups: [{ gs: 0, students: 2 }], + outcome_count: 0 + ) + + group_task = create( + :task_definition, + unit: group_unit, + group_set: group_unit.group_sets.first, + target_grade: 0, + upload_requirements: [], + start_date: 1.day.ago, + outcome_count: 0 + ) + + projects = group_unit.groups.first.projects.to_a + projects.each { |project| project.update!(target_grade: 0) } + + submitting_task = + projects.first.task_for_task_definition(group_task) + + contributions = projects.map do |project| + { + project_id: project.id, + pct: 100 / projects.length, + pts: 3 + } + end + + submitting_task.create_submission_and_trigger_state_change( + submitting_task.student, + true, + contributions, + 'ready_for_feedback' + ) + + PeerProgressAggregationService.call( + unit: group_unit, + calculated_at: @calculated_at + ) + + snapshot = PeerProgressSnapshot.find_by!( + unit: group_unit, + task_definition: group_task, + target_grade: 0 + ) + + assert_equal 2, snapshot.cohort_size + assert_equal 100.0, snapshot.submitted_percentage.to_f + + projects.each do |project| + task = project.tasks.find_by!( + task_definition: group_task + ) + + assert task.file_uploaded_at.present? + end + end + def run_service PeerProgressAggregationService.call( unit: @unit, @@ -340,12 +438,15 @@ def create_submitted_task( task_definition:, task_status: TaskStatus.ready_for_feedback ) + uploaded_at = @calculated_at - 1.hour + create( :task, project: project, task_definition: task_definition, task_status: task_status, - submission_date: @calculated_at - 1.hour + file_uploaded_at: uploaded_at, + submission_date: uploaded_at ) end end From 58be631ef391fcb015a95f8e8b9188b124c0b2cd Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Tue, 11 Aug 2026 17:02:20 +1000 Subject: [PATCH 035/247] fix(ppi): enforce effective student release dates --- app/api/peer_progress_api.rb | 27 +++++++++++-- test/api/peer_progress_api_test.rb | 65 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb index d72c7e5abc..d8c0c0e368 100644 --- a/app/api/peer_progress_api.rb +++ b/app/api/peer_progress_api.rb @@ -19,6 +19,26 @@ def peer_progress_not_found! error!({ error: PeerProgressApi::NOT_FOUND_MESSAGE }, 404) end + def effective_task(project:, task_definition:) + project.tasks.find_by( + task_definition_id: task_definition.id + ) || Task.new( + project: project, + task_definition: task_definition, + task_status: TaskStatus.not_started, + extensions: 0 + ) + end + + def released_for_project?(project:, task_definition:) + start_date = effective_task( + project: project, + task_definition: task_definition + ).local_start_date + + start_date.present? && start_date <= Time.zone.now + end + def safe_target_grade(project) target_grade = project.target_grade @@ -157,9 +177,10 @@ def peer_progress_result(project:, task_definition:) ) peer_progress_not_found! if task_definition.nil? - released = task_definition.start_date.present? && - task_definition.start_date <= Time.zone.now - peer_progress_not_found! unless released + peer_progress_not_found! unless released_for_project?( + project: project, + task_definition: task_definition + ) target_grade = project.target_grade if target_grade.present? && unit.grade_value?(target_grade) && diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index ee66672329..3cc2173f27 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -132,6 +132,71 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal '', body['unavailable_message'] end + test 'does not allow access before a student specific flexible start date' do + @unit.update!(allow_flexible_dates: true) + + create( + :task, + project: @project, + task_definition: @task_definition, + task_status: TaskStatus.not_started, + target_start_date: 1.day.from_now + ) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'does not allow access before a target grade specific start date' do + @unit.update!(allow_flexible_dates: true) + + TaskDefinitionGradeDueDate.create!( + task_definition: @task_definition, + target_grade: @project.target_grade, + start_date: 1.day.from_now, + target_due_date: @task_definition.target_date + ) + + request_as(@student) + + assert_peer_progress_not_found + end + + test 'allows access after the target grade specific start date' do + @unit.update!(allow_flexible_dates: true) + + TaskDefinitionGradeDueDate.create!( + task_definition: @task_definition, + target_grade: @project.target_grade, + start_date: 1.day.ago, + target_due_date: @task_definition.target_date + ) + + create_snapshot( + submitted_percentage: 50, + cohort_size: 5 + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 50.0, last_response_body['submitted_percentage'] + end + + test 'does not create a task row while checking the release date' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 5 + ) + + assert_no_difference('Task.count') do + request_as(@student) + end + + assert_equal 200, last_response.status + end + test 'does not allow a student to read another students project' do other_student = create(:user, :student) other_project = @unit.enrol_student( From bc2d24d778d1bcd00a7d835d4fb40a4dcc7f4c13 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Tue, 11 Aug 2026 17:05:42 +1000 Subject: [PATCH 036/247] fix(ppi): enforce the cohort privacy floor --- app/api/peer_progress_api.rb | 19 ++++++++++++++--- test/api/peer_progress_api_test.rb | 33 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb index d8c0c0e368..204e2b89fa 100644 --- a/app/api/peer_progress_api.rb +++ b/app/api/peer_progress_api.rb @@ -8,6 +8,7 @@ class PeerProgressApi < Grape::API UNAVAILABLE_MESSAGE = 'Peer progress is currently unavailable.' NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.' CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.' + MINIMUM_SAFE_COHORT_SIZE = 5 before do header 'Cache-Control', 'private, no-store' @@ -57,6 +58,19 @@ def positive_integer_env!(name) error!({ error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, 503) end + def minimum_cohort_size! + value = positive_integer_env!( + 'DF_PPI_MINIMUM_COHORT_SIZE' + ) + + return value if value >= PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + + error!( + { error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, + 503 + ) + end + def peer_progress_payload( project:, task_definition:, @@ -116,9 +130,8 @@ def peer_progress_result(project:, task_definition:) ) end - minimum_cohort_size = positive_integer_env!( - 'DF_PPI_MINIMUM_COHORT_SIZE' - ) + minimum_cohort_size = minimum_cohort_size! + stale_after_hours = positive_integer_env!( 'DF_PPI_STALE_AFTER_HOURS' ) diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index 3cc2173f27..8f4747b57f 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -197,6 +197,39 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status end + test 'fails closed when the cohort configuration is below the privacy floor' do + create_snapshot( + submitted_percentage: 50, + cohort_size: 5 + ) + + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '4' + + request_as(@student) + + assert_equal 503, last_response.status + assert_equal( + PeerProgressApi::CONFIG_ERROR_MESSAGE, + last_response_body['error'] + ) + assert_private_no_store + end + + test 'accepts a configured threshold above the privacy floor' do + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '6' + + create_snapshot( + submitted_percentage: 50, + cohort_size: 6 + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 50.0, last_response_body['submitted_percentage'] + assert_equal false, last_response_body['is_suppressed'] + end + test 'does not allow a student to read another students project' do other_student = create(:user, :student) other_project = @unit.enrol_student( From 15788345d2275c3d818f70e71a5ce5feb799583e Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Tue, 11 Aug 2026 17:07:45 +1000 Subject: [PATCH 037/247] fix(ppi): isolate aggregation work by unit --- app/sidekiq/aggregate_peer_progress_job.rb | 59 +++++++++++------- .../aggregate_peer_progress_job_test.rb | 62 +++++++++++++------ 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/app/sidekiq/aggregate_peer_progress_job.rb b/app/sidekiq/aggregate_peer_progress_job.rb index 01e4fdb783..b74236b133 100644 --- a/app/sidekiq/aggregate_peer_progress_job.rb +++ b/app/sidekiq/aggregate_peer_progress_job.rb @@ -7,36 +7,40 @@ class AggregatePeerProgressJob include ApplicationHelper sidekiq_options lock: :until_executed, - lock_args_method: ->(args) { [args.first] }, + lock_args_method: lambda { |args| + [args.first || 'all-active-units'] + }, on_conflict: :reject, - retry: false + retry: 3 def perform(unit_id = nil) - logger.info 'Starting peer progress aggregation...' + return enqueue_active_units if unit_id.blank? - at(0) - total(1) - - calculated_at = Time.zone.now - - if unit_id.present? - aggregate_unit(Unit.find(unit_id), calculated_at) - else - Unit.active_units.find_each do |unit| - aggregate_unit(unit, calculated_at) - end - end - - at(1) - logger.info 'Completed peer progress aggregation!' + aggregate_unit(Unit.find(unit_id)) rescue StandardError => e - logger.error "Peer progress aggregation failed: #{e.class}: #{e.message}" + logger.error( + "Peer progress aggregation failed: #{e.class}: #{e.message}" + ) raise end private - def aggregate_unit(unit, calculated_at) + def enqueue_active_units + logger.info( + 'Queueing peer progress aggregation for active units...' + ) + + Unit.active_units.find_each do |unit| + self.class.perform_async(unit.id) + end + + logger.info( + 'Queued peer progress aggregation jobs.' + ) + end + + def aggregate_unit(unit) unless unit.active? logger.info( "Skipping peer progress aggregation for inactive unit_id=#{unit.id}" @@ -44,9 +48,22 @@ def aggregate_unit(unit, calculated_at) return end + logger.info( + "Starting peer progress aggregation for unit_id=#{unit.id}..." + ) + + at(0) + total(1) + PeerProgressAggregationService.call( unit: unit, - calculated_at: calculated_at + calculated_at: Time.zone.now + ) + + at(1) + + logger.info( + "Completed peer progress aggregation for unit_id=#{unit.id}." ) end end diff --git a/test/sidekiq/aggregate_peer_progress_job_test.rb b/test/sidekiq/aggregate_peer_progress_job_test.rb index e46b6fc56f..18dae89cac 100644 --- a/test/sidekiq/aggregate_peer_progress_job_test.rb +++ b/test/sidekiq/aggregate_peer_progress_job_test.rb @@ -33,31 +33,52 @@ def test_aggregates_the_requested_active_unit assert_equal @calculated_at, calls.first[:calculated_at] end - def test_aggregates_all_active_units_when_no_unit_id_is_given - expected_unit_ids = Unit.active_units.order(:id).pluck(:id) - calls = [] + def test_enqueues_one_job_for_each_active_unit_when_no_unit_id_is_given + Sidekiq::Job.clear_all - travel_to @calculated_at do - PeerProgressAggregationService.stub( - :call, - lambda do |unit:, calculated_at:| - calls << { - unit_id: unit.id, - calculated_at: calculated_at - } - [] - end - ) do - AggregatePeerProgressJob.new.perform - end + expected_unit_ids = + Unit.active_units.order(:id).pluck(:id) + + assert_difference( + -> { AggregatePeerProgressJob.jobs.size }, + expected_unit_ids.length + ) do + AggregatePeerProgressJob.new.perform end - actual_unit_ids = calls.map { |call| call[:unit_id] }.sort - calculated_times = calls.map { |call| call[:calculated_at] }.uniq + actual_unit_ids = + AggregatePeerProgressJob.jobs + .last(expected_unit_ids.length) + .map { |job| job['args'].first } + .sort assert_equal expected_unit_ids, actual_unit_ids - assert_equal [@calculated_at], calculated_times - assert_not_includes expected_unit_ids, @inactive_unit.id + assert_not_includes actual_unit_ids, @inactive_unit.id + end + + def test_failure_for_one_unit_does_not_prevent_another_unit_job + other_unit = create_minimal_unit(active: true) + successful_unit_ids = [] + + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, **_kwargs| + if unit.id == @active_unit.id + raise StandardError, 'first unit failed' + end + + successful_unit_ids << unit.id + [] + end + ) do + assert_raises(StandardError) do + AggregatePeerProgressJob.new.perform(@active_unit.id) + end + + AggregatePeerProgressJob.new.perform(other_unit.id) + end + + assert_equal [other_unit.id], successful_unit_ids end def test_skips_a_requested_inactive_unit @@ -130,6 +151,7 @@ def test_creates_a_snapshot_through_the_real_aggregation_service project: projects.first, task_definition: task_definition, task_status: TaskStatus.ready_for_feedback, + file_uploaded_at: @calculated_at - 1.hour, submission_date: @calculated_at - 1.hour ) From e7950befa03e99b4caf946f600c2f901fcdbedcc Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Tue, 11 Aug 2026 17:10:30 +1000 Subject: [PATCH 038/247] feat(ppi): add authorised unit feature management --- app/api/entities/unit_entity.rb | 5 +++ app/api/units_api.rb | 2 + test/api/units_api_test.rb | 68 +++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb index 46f26976be..f957e57272 100644 --- a/app/api/entities/unit_entity.rb +++ b/app/api/entities/unit_entity.rb @@ -55,6 +55,11 @@ def can_read_unit_config?(my_role) expose :allow_student_change_tutorial, unless: :summary_only expose :allow_flexible_dates, unless: :summary_only expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only + expose :peer_progress_enabled, + unless: :summary_only, + if: lambda { |_unit, options| + can_read_unit_config?(options[:my_role]) + } expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 411065f97e..9df7ae00c9 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -73,6 +73,7 @@ class UnitsApi < Grape::API optional :code, type: String optional :description, type: String optional :active, type: Boolean + optional :peer_progress_enabled, type: Boolean, desc: 'Enable anonymous peer progress for students in this unit' optional :teaching_period_id, type: Integer optional :start_date, type: Date optional :end_date, type: Date @@ -116,6 +117,7 @@ class UnitsApi < Grape::API :description, :start_date, :end_date, + :peer_progress_enabled, :teaching_period_id, :active, :main_convenor_id, diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index 23add5b13e..5a22933930 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -488,6 +488,74 @@ def test_put_update_unit_invalid_id assert_equal 404, last_response.status end + def test_main_convenor_can_enable_peer_progress + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0 + ) + + add_auth_header_for(user: unit.main_convenor_user) + + put_json( + "/api/units/#{unit.id}", + { + unit: { + peer_progress_enabled: true + } + } + ) + + assert_equal 200, last_response.status, last_response_body + assert unit.reload.peer_progress_enabled? + assert_equal true, last_response_body['peer_progress_enabled'] + end + + def test_student_cannot_enable_peer_progress + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + tutorials: 1 + ) + + student = FactoryBot.create(:user, :student) + unit.enrol_student( + student, + unit.tutorials.first.campus + ) + + add_auth_header_for(user: student) + + put_json( + "/api/units/#{unit.id}", + { + unit: { + peer_progress_enabled: true + } + } + ) + + assert_equal 403, last_response.status + assert_not unit.reload.peer_progress_enabled? + end + + def test_unit_details_expose_peer_progress_setting_to_the_convenor + unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + peer_progress_enabled: true + ) + + add_auth_header_for(user: unit.main_convenor_user) + + get "/api/units/#{unit.id}" + + assert_equal 200, last_response.status + assert_equal true, last_response_body['peer_progress_enabled'] + end + # Test can update unit start and end dates def test_put_update_unit_dates # Add username and auth_token to Header From 29147bcd10a8b9947f2579d41d7bd8a5147e9ba1 Mon Sep 17 00:00:00 2001 From: Leeon Gourav Rangey Date: Wed, 12 Aug 2026 21:19:52 +1000 Subject: [PATCH 039/247] feat(notifications): email on extension decision --- app/models/comments/extension_comment.rb | 66 +++++--- .../extension_assessed.html.erb | 20 +++ .../extension_assessed.text.erb | 12 ++ .../events/extension_assessed.md | 22 +++ test/models/notification_extension_test.rb | 141 ++++++++++++++++++ 5 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 app/views/notifications_mailer/extension_assessed.html.erb create mode 100644 app/views/notifications_mailer/extension_assessed.text.erb create mode 100644 docs/notifications/events/extension_assessed.md create mode 100644 test/models/notification_extension_test.rb diff --git a/app/models/comments/extension_comment.rb b/app/models/comments/extension_comment.rb index abd9d1030c..82b982ac26 100644 --- a/app/models/comments/extension_comment.rb +++ b/app/models/comments/extension_comment.rb @@ -27,32 +27,52 @@ def mark_as_read(user, unit = self.unit) super if assessed? || user == project.student || user != recipient end - def assess_extension(user, granted, automatic = false) - if self.assessed? - self.errors[:extension] << 'has already been assessed' - return false - end +def assess_extension(user, granted, automatic = false) + if self.assessed? + self.errors[:extension] << 'has already been assessed' + return false + end + + self.assessor = user + self.date_extension_assessed = Time.zone.now + self.extension_granted = granted && self.task.can_apply_for_extension? + + should_notify = true + + if self.extension_granted + self.task.grant_extension(user, extension_weeks) - self.assessor = user - self.date_extension_assessed = Time.zone.now - self.extension_granted = granted && self.task.can_apply_for_extension? - - if self.extension_granted - self.task.grant_extension(user, extension_weeks) - if automatic - self.extension_response = "Time extended to #{self.task.due_date.strftime('%a %b %e')}" - else - self.extension_response = "Extension granted to #{self.task.due_date.strftime('%a %b %e')}" - end - elsif !self.task.can_apply_for_extension? && granted - self.extension_response = "Extension cannot be granted as deadline has been reached" - errors[:extension] << 'cannot be granted as deadline has been reached' + if automatic + self.extension_response = "Time extended to #{self.task.due_date.strftime('%a %b %e')}" else - self.extension_response = "Extension rejected" + self.extension_response = "Extension granted to #{self.task.due_date.strftime('%a %b %e')}" end + elsif !self.task.can_apply_for_extension? && granted + self.extension_response = "Extension cannot be granted as deadline has been reached" + errors[:extension] << 'cannot be granted as deadline has been reached' + should_notify = false + else + self.extension_response = "Extension rejected" + end + + # Now make sure to read it by the main tutor - even if assessed by someone else + super_mark_as_read(project.tutor_for(task.task_definition)) + save! - # Now make sure to read it by the main tutor - even if assessed by someone else - super_mark_as_read(project.tutor_for(task.task_definition)) - save! + if should_notify + begin + NotificationService.notify( + user: project.student, + type: 'extension', + event: 'extension_assessed', + message: extension_response, + link: "/projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" + ) + rescue StandardError => e + Rails.logger.error "Failed to notify student about extension assessment: #{e.message}" + end end + + true +end end diff --git a/app/views/notifications_mailer/extension_assessed.html.erb b/app/views/notifications_mailer/extension_assessed.html.erb new file mode 100644 index 0000000000..2fc33dabfb --- /dev/null +++ b/app/views/notifications_mailer/extension_assessed.html.erb @@ -0,0 +1,20 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ Your extension request has been assessed. + Open the task in <%= @doubtfire_product_name %> to review the current task details. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ This notification is sent when an extension request is assessed. +

\ No newline at end of file diff --git a/app/views/notifications_mailer/extension_assessed.text.erb b/app/views/notifications_mailer/extension_assessed.text.erb new file mode 100644 index 0000000000..a5042f2587 --- /dev/null +++ b/app/views/notifications_mailer/extension_assessed.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +Your extension request has been assessed. +Open the task in <%= @doubtfire_product_name %> to review the current task details. + +<% if @notification.link.present? -%> +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +This notification is sent when an extension request is assessed. \ No newline at end of file diff --git a/docs/notifications/events/extension_assessed.md b/docs/notifications/events/extension_assessed.md new file mode 100644 index 0000000000..813613ff1f --- /dev/null +++ b/docs/notifications/events/extension_assessed.md @@ -0,0 +1,22 @@ +# Event: extension_assessed + +## What it does + +A tutor assesses a student's extension request. + +- If the extension is granted, the student is notified and the message includes the new due date. +- If the extension is denied, the student is notified that the request was rejected. +- Failed assessment paths do not send a notification. + +## Where it is raised + +`app/models/comments/extension_comment.rb`, inside `assess_extension`, after the extension assessment has successfully saved. + +```ruby +NotificationService.notify( + user: project.student, + type: 'extension', + event: 'extension_assessed', + message: extension_response, + link: "/projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" +) \ No newline at end of file diff --git a/test/models/notification_extension_test.rb b/test/models/notification_extension_test.rb new file mode 100644 index 0000000000..25ef5034c1 --- /dev/null +++ b/test/models/notification_extension_test.rb @@ -0,0 +1,141 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-E03: assessing an extension request notifies the student. +class NotificationExtensionTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @task_definition = @unit.task_definitions.first + @task = @project.task_for_task_definition(@task_definition) + + @student = @project.student + @tutor = @project.tutor_for(@task_definition) + + # Prevent the request being assessed automatically when it is created. + @unit.update!(auto_apply_extension_before_deadline: false) + end + + def create_extension_request + @task.apply_for_extension( + @student, + 'Please grant me an extension.', + 1 + ) + end + + def delivered_parts + mail = ActionMailer::Base.deliveries.last + + { + html: mail&.html_part&.body&.decoded.to_s, + text: mail&.text_part&.body&.decoded.to_s + } + end + + def test_granted_extension_notifies_student_with_new_date + extension = create_extension_request + + @task.stub :can_apply_for_extension?, true do + assert_difference 'Notification.count', 1 do + extension.assess_extension(@tutor, true) + end + end + + extension.reload + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'extension', notification.notification_type + assert_equal 'extension_assessed', notification.event + + assert extension.extension_granted + assert_includes notification.message, 'Extension granted' + assert_includes( + notification.message, + @task.reload.due_date.strftime('%a %b %e') + ) + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + + parts = delivered_parts + + assert_not_empty parts[:html] + assert_not_empty parts[:text] + + assert_includes parts[:html], notification.message + assert_includes parts[:text], notification.message + + assert_includes parts[:html], notification.link + assert_includes parts[:text], notification.link + end + + def test_denied_extension_notifies_student + extension = create_extension_request + + assert_difference 'Notification.count', 1 do + extension.assess_extension(@tutor, false) + end + + extension.reload + notification = Notification.recent_first.first + + refute extension.extension_granted + + assert_equal @student, notification.user + assert_equal 'extension', notification.notification_type + assert_equal 'extension_assessed', notification.event + assert_equal 'Extension rejected', notification.message + + assert_equal 1, ActionMailer::Base.deliveries.count + + parts = delivered_parts + + assert_includes parts[:html], 'Extension rejected' + assert_includes parts[:text], 'Extension rejected' + end + + def test_already_assessed_extension_does_not_send_another_notification + extension = create_extension_request + + extension.assess_extension(@tutor, false) + + ActionMailer::Base.deliveries.clear + + assert_no_difference 'Notification.count' do + result = extension.assess_extension(@tutor, true) + + assert_equal false, result + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_deadline_error_does_not_send_notification + extension = create_extension_request + + ActionMailer::Base.deliveries.clear + + @task.stub :can_apply_for_extension?, false do + assert_no_difference 'Notification.count' do + extension.assess_extension(@tutor, true) + end + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_extension_notification_uses_event_specific_templates + extension = create_extension_request + + extension.assess_extension(@tutor, false) + + parts = delivered_parts + + assert_includes parts[:html], 'Your extension request has been assessed' + assert_includes parts[:text], 'Your extension request has been assessed' + end +end \ No newline at end of file From 686cae28c9bb425a4ae87858efc4717de38b55b2 Mon Sep 17 00:00:00 2001 From: Kimsreng Date: Thu, 13 Aug 2026 23:59:33 +1000 Subject: [PATCH 040/247] docs(pwa): document offline behaviour --- docs/notifications/pwa-offline.md | 124 ++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/notifications/pwa-offline.md diff --git a/docs/notifications/pwa-offline.md b/docs/notifications/pwa-offline.md new file mode 100644 index 0000000000..9b118370dc --- /dev/null +++ b/docs/notifications/pwa-offline.md @@ -0,0 +1,124 @@ +# PWA offline behaviour + +What a user sees if they lose connection while using OnTrack, and why. + +## What is cached, and what is not + +`ngsw-config.json` (doubtfire-web) has two kinds of cache group. The `app` and +`assets` asset groups cache the static shell — `index.html`, the compiled JS +and CSS bundles, and images — with `installMode: prefetch`, so the shell +downloads up front. That part of the PWA is designed to work offline. + +The `api` data group is deliberately excluded: + +```json +{ + "name": "api", + "urls": ["/api"], + "cacheConfig": { + "maxSize": 0, + "maxAge": "0u", + "strategy": "freshness" + } +} +``` + +`maxSize: 0` means the cache holds zero entries, so there is never anything +to fall back to. `strategy: freshness` is network-first, and with nothing +cached, a failed network request has no cached response behind it — it just +fails. In practice: **no API response is ever served from the service +worker's cache, under any condition.** + +### The maxSize 0 choice + +This traces back to commit `ab5a30a`, "FIX: Ensure api/ data is not cached" +(2020). The commit message does not elaborate further than that, but the +reasoning is not hard to infer: API responses here are grades, task status, +submissions and extension state — exactly the data where showing something +stale would be actively misleading, not just inconvenient. A short-TTL cache +would still risk a tutor or student acting on an out-of-date number. Opting +API traffic out of the cache entirely avoids that risk, at the cost of any +offline API access at all. If a more specific justification than this exists, +it hasn't been written down anywhere in the codebase — worth confirming with +the lead if it matters for a future decision. + +No caching behaviour was changed to investigate this ticket. The above is a +description of the existing config, not a proposal. + +## What actually happens offline + +Tested in Chrome DevTools (Network tab → Offline), against the dev stack with +the service worker confirmed active and controlling the page (`Application` → +`Service Workers` showed `ngsw-worker.js` "activated and running" before each +test below). + +### Scenario 1: reloading a page while offline + +Navigating to `localhost:4200/projects/28/dashboard/A15` and reloading while +offline does not show any OnTrack UI. Chrome shows its own native offline +page — "This page isn't working, localhost took too long to respond, HTTP +ERROR 504." The network log confirms the top-level document request itself +failed outright rather than being served from the service worker's cached +shell. + +So despite the service worker being registered and running, a hard reload +while offline does not fall back to a cached app shell. The user gets a +generic browser error with no indication it's OnTrack-specific, and no way +to retry from within the app. + +### Scenario 2: losing connection mid-session + +More realistic: the app is already loaded and the user goes offline without +reloading, then navigates to a task they haven't opened yet in that session +(client-side routing, no full page load). + +Here the shell and anything already in memory stay up — the task list +sidebar, and top-level task fields (title, due date, status) that were part +of an earlier list fetch, render fine. But the secondary fetches that page +needs (`prerequisites`, `submission_details`, `comments`) mostly fail. Some +of the same-looking requests returned `200`/`304` and some returned `504` or +failed outright — the successes are ordinary browser HTTP cache hits for +URLs already fetched earlier in the session, not the service worker's own +data cache, which is disabled by `maxSize: 0`. Anything not already fetched +before going offline has nothing to fall back to and fails. + +When a fetch fails, the app does not degrade gracefully. It surfaced this to +the user as a toast: + +> Failed to fetch prerequisites for task definition: TypeError: Cannot read +> properties of null (reading 'error') + +That's an unhandled null dereference, not an offline message — the error +handling path assumes a response body is always present and throws when +there isn't one. A user sees a confusing technical error rather than +anything telling them they're offline. + +## Summary + +The `api` group's no-cache config guarantees a user is never shown stale +academic data, which is clearly the intent. The tradeoff is that there is no +designed offline mode at all: depending on whether they reload or just keep +navigating, a disconnected user gets either a browser-level 504 page or a +raw JS error toast. Neither tells them they're offline, and neither offers a +retry. + +## Recommendation + +Out of scope here — this ticket is documentation only, no caching behaviour +was changed. If the team wants an actual offline UX (a banner, a clear +"you're offline, reconnect to continue" state, or handling the null response +case without throwing), that belongs in a separate ticket. + +## How to check it by hand + +1. Open the app, sign in, and confirm the service worker is active: + DevTools → `Application` → `Service Workers` → status should read + "activated and is running." +2. **Reload case:** DevTools → `Network` → set throttling to `Offline`, + then reload the page. Expect Chrome's native offline error page, not + OnTrack. +3. **Mid-session case:** with the app already loaded, switch to `Offline` + without reloading, then click into a task not yet opened this session. + Expect the shell to stay up, task list data already in memory to render, + and any new data fetch (prerequisites, submission details, comments) to + fail — watch for an unhandled error toast rather than an offline message. \ No newline at end of file From 0af991f0ea58a295389b0af3a5144885584718e6 Mon Sep 17 00:00:00 2001 From: Leeon Gourav Rangey Date: Fri, 14 Aug 2026 00:17:20 +1000 Subject: [PATCH 041/247] feat(notifications): email when a new task becomes available --- app/api/task_definitions_api.rb | 2 + .../new_task_available_notification_job.rb | 68 ++++++ .../new_task_available.html.erb | 22 ++ .../new_task_available.text.erb | 12 ++ .../events/new_task_available.md | 115 ++++++++++ test/models/notification_new_task_test.rb | 197 ++++++++++++++++++ 6 files changed, 416 insertions(+) create mode 100644 app/sidekiq/new_task_available_notification_job.rb create mode 100644 app/views/notifications_mailer/new_task_available.html.erb create mode 100644 app/views/notifications_mailer/new_task_available.text.erb create mode 100644 docs/notifications/events/new_task_available.md create mode 100644 test/models/notification_new_task_test.rb diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 5e0be83ee7..70e84df196 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -109,6 +109,8 @@ class TaskDefinitionsApi < Grape::API task_def.save! + NewTaskAvailableNotificationJob.perform_async(task_def.id) + present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) end diff --git a/app/sidekiq/new_task_available_notification_job.rb b/app/sidekiq/new_task_available_notification_job.rb new file mode 100644 index 0000000000..3dc5f03159 --- /dev/null +++ b/app/sidekiq/new_task_available_notification_job.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class NewTaskAvailableNotificationJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'new_task_available' + TYPE = 'task' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(task_definition_id) + task_definition = TaskDefinition.find_by(id: task_definition_id) + return if task_definition.nil? + + unit = task_definition.unit + return unless unit.active + + unit.projects + .where(enrolled: true) + .includes(:user) + .find_each(batch_size: BATCH_SIZE) do |project| + notify_project(project, task_definition) + end + end + + private + + def notify_project(project, task_definition) + return if project.target_grade.nil? + return if task_definition.target_grade > project.target_grade + + task = project.task_for_task_definition(task_definition) + return if task.nil? + + # A newly created task is only available when the student's effective + # start date has arrived. Task#local_start_date includes flexible, + # grade-specific and student-specific date adjustments. + return if task.local_start_date.to_date > Time.zone.today + + student = project.student + link = "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + + # Protect against the fan-out job being run more than once. + return if Notification.exists?( + user_id: student.id, + notification_type: TYPE, + event: EVENT, + link: link + ) + + NotificationService.notify( + user: student, + type: TYPE, + event: EVENT, + message: "A new task is available: #{task_definition.abbreviation} in #{task_definition.unit.code}.", + link: link + ) + rescue StandardError => e + Rails.logger.error( + "Failed new-task notification for TaskDefinition #{task_definition.id}, " \ + "Project #{project.id}: #{e.class} - #{e.message}" + ) + end +end \ No newline at end of file diff --git a/app/views/notifications_mailer/new_task_available.html.erb b/app/views/notifications_mailer/new_task_available.html.erb new file mode 100644 index 0000000000..fcd4223fd9 --- /dev/null +++ b/app/views/notifications_mailer/new_task_available.html.erb @@ -0,0 +1,22 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ A new task is now available in <%= @doubtfire_product_name %>. + Open the task to review its details. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at + your profile. +

\ No newline at end of file diff --git a/app/views/notifications_mailer/new_task_available.text.erb b/app/views/notifications_mailer/new_task_available.text.erb new file mode 100644 index 0000000000..a53d604fc8 --- /dev/null +++ b/app/views/notifications_mailer/new_task_available.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +A new task is now available in <%= @doubtfire_product_name %>. +Open the task to review its details. + +<% if @notification.link.present? -%> +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. \ No newline at end of file diff --git a/docs/notifications/events/new_task_available.md b/docs/notifications/events/new_task_available.md new file mode 100644 index 0000000000..3afae27b2b --- /dev/null +++ b/docs/notifications/events/new_task_available.md @@ -0,0 +1,115 @@ +# New Task Available Notification + +## Event + +`new_task_available` + +## Purpose + +Notifies eligible students when a new task becomes available through the normal convenor task-creation workflow. + +## Trigger + +The notification fan-out is queued after a new task definition is successfully created through the normal task-definition API. + +The hook is located in: + +`app/api/task_definitions_api.rb` + +immediately after: + +`task_def.save!` + +A general `TaskDefinition` `after_create` callback is intentionally not used because task definitions are also created through rollover, copy and import workflows. Triggering from the model could therefore generate unexpected or duplicate notifications. + +## Notification + +- Type: `task` +- Event: `new_task_available` +- Recipient: eligible students enrolled in the unit +- Preference: `receive_task_notifications` + +`NotificationService` applies the existing task-notification preference before creating and delivering the notification. + +## Recipient eligibility + +A student receives the notification only when all of the following are true: + +- The task was created through the normal convenor API. +- The unit is active. +- The student is currently enrolled in the unit. +- The task applies to the student's target grade. +- The student's effective task start date is now or earlier. +- The student has task notifications enabled. + +The student's effective start date is determined using the existing `Task#local_start_date` behaviour so that flexible dates, target-grade dates and supported student-specific date adjustments are respected. + +## Fan-out + +Notification delivery is not performed directly inside the API request. + +After the task definition is saved, the API enqueues: + +`NewTaskAvailableNotificationJob` + +The job processes enrolled projects in batches and sends one notification to each eligible student. + +Duplicate notifications for the same student and task are prevented if the fan-out job is executed more than once. + +## Email templates + +HTML: + +`app/views/notifications_mailer/new_task_available.html.erb` + +Plain text: + +`app/views/notifications_mailer/new_task_available.text.erb` + +## Link + +The notification links the student to the new task on their project dashboard: + +`/projects/:project_id/dashboard/:task_abbreviation` + +## Future-dated tasks + +Students whose effective task start date is in the future are not notified when the task definition is created. + +The create endpoint will not execute again when that future start date arrives. + +Scheduled release-time notifications for future-dated tasks are therefore outside the scope of this first EN-V02 implementation and should be handled as follow-up work. + +## Out of scope + +The following task-definition creation paths are deliberately outside this first implementation: + +- unit rollover +- task copying +- CSV/import workflows +- scheduled notifications when a future effective start date is reached + +## Tests + +Automated tests are located at: + +`test/models/notification_new_task_test.rb` + +The tests cover: + +- notification for an eligible student +- fan-out to multiple eligible students +- task-notification preference disabled +- unenrolled students +- task target-grade eligibility +- inactive units +- future effective start dates +- duplicate prevention + +Test command: + +`bundle exec rails test test/models/notification_new_task_test.rb` + +Current result: + +`8 runs, 52 assertions, 0 failures, 0 errors, 0 skips` \ No newline at end of file diff --git a/test/models/notification_new_task_test.rb b/test/models/notification_new_task_test.rb new file mode 100644 index 0000000000..e4fe884288 --- /dev/null +++ b/test/models/notification_new_task_test.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'test_helper' + +# EN-V02: newly available tasks notify eligible students. +class NotificationNewTaskTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + + @unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + tutorials: 0, + outcome_count: 0, + staff_count: 0, + campus_count: 1, + active: true, + start_date: Time.zone.now - 1.week, + end_date: Time.zone.now + 12.weeks + ) + + @campus = Campus.first + + @student = FactoryBot.create( + :user, + :student, + receive_task_notifications: true + ) + + @project = FactoryBot.create( + :project, + unit: @unit, + campus: @campus, + user: @student, + enrolled: true, + target_grade: 2 + ) + + @task_definition = FactoryBot.create( + :task_definition, + unit: @unit, + outcome_count: 0, + target_grade: 1, + start_date: Time.zone.now - 1.day, + target_date: Time.zone.now + 1.week, + due_date: Time.zone.now + 2.weeks + ) + end + + def run_job + NewTaskAvailableNotificationJob.new.perform(@task_definition.id) + end + + def event_notifications + Notification.where(event: 'new_task_available') + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + if mail.multipart? + mail.parts.map { |part| part.body.decoded }.join("\n") + else + mail.body.decoded + end + end + + def test_available_task_notifies_eligible_student + assert_difference 'Notification.count', 1 do + run_job + end + + notification = event_notifications.last + + assert_equal @student, notification.user + assert_equal 'task', notification.notification_type + assert_equal 'new_task_available', notification.event + + assert_includes notification.message, @task_definition.abbreviation + assert_includes notification.message, @unit.code + + assert_equal( + "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}", + notification.link + ) + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + + body = delivered_body + + assert_not_empty body + assert_includes body, 'A new task is now available' + assert_includes body, @task_definition.abbreviation + end + + def test_fans_out_to_each_eligible_student + second_student = FactoryBot.create( + :user, + :student, + receive_task_notifications: true + ) + + FactoryBot.create( + :project, + unit: @unit, + campus: @campus, + user: second_student, + enrolled: true, + target_grade: 2 + ) + + assert_difference 'Notification.count', 2 do + run_job + end + + recipients = event_notifications.includes(:user).map(&:user) + + assert_includes recipients, @student + assert_includes recipients, second_student + assert_equal 2, ActionMailer::Base.deliveries.count + end + + def test_student_with_task_notifications_disabled_is_not_notified + @student.update!(receive_task_notifications: false) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_unenrolled_student_is_not_notified + @project.update!(enrolled: false) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_student_below_task_target_grade_is_not_notified + @project.update!(target_grade: 0) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_inactive_unit_does_not_send_notifications + @unit.update!(active: false) + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_future_effective_student_start_date_is_not_notified + @unit.update!(allow_flexible_dates: true) + + task = @project.task_for_task_definition(@task_definition) + + task.update!( + target_start_date: Time.zone.now + 2.days + ) + + assert_operator task.local_start_date.to_date, :>, Time.zone.today + + assert_no_difference 'Notification.count' do + run_job + end + + assert_empty ActionMailer::Base.deliveries + end + + def test_running_fan_out_twice_does_not_duplicate_notification + run_job + + assert_equal 1, event_notifications.count + assert_equal 1, ActionMailer::Base.deliveries.count + + assert_no_difference 'Notification.count' do + run_job + end + + assert_equal 1, event_notifications.count + assert_equal 1, ActionMailer::Base.deliveries.count + end +end \ No newline at end of file From 35b0d2dfe247cae362893349c22eb502007f56b2 Mon Sep 17 00:00:00 2001 From: Ronit Khokhar Date: Fri, 14 Aug 2026 00:18:11 +1000 Subject: [PATCH 042/247] docs(notifications): audit existing email send sites --- docs/notifications/existing-emails.md | 161 ++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/notifications/existing-emails.md diff --git a/docs/notifications/existing-emails.md b/docs/notifications/existing-emails.md new file mode 100644 index 0000000000..6ed3d575d6 --- /dev/null +++ b/docs/notifications/existing-emails.md @@ -0,0 +1,161 @@ +# Existing Email Audit + +This document records the email behaviour that already exists in OnTrack before +the v2 notification work. Its purpose is to prevent new notification events from +duplicating existing email behaviour or accidentally sending multiple messages +for the same action. + +The audit covers both the API mailers and the existing communication subsystem +in the web application. + +## API email send sites + +A search of `doubtfire-api` for `.deliver`, `.deliver_now`, and +`.deliver_later` identified 21 real email send sites. Push notification service +calls and method definitions are not counted as email send sites. + +| Existing email | Trigger / send site | Recipient | Preference / guard | +| --- | --- | --- | --- | +| Turnitin error log | `app/helpers/turn_it_in.rb:88` – Turnitin credential/error handling | Configured administrator/error-log address | None observed | +| Task PDF failed | `app/models/portfolio_evidence.rb:79` – task PDF generation failure | Project student | Task notification preference | +| Weekly student summary | `app/models/project.rb:682` – project weekly summary | Project student | Existing summary eligibility/preference logic | +| Task feedback ready | `app/models/unit.rb:2975` – feedback/PDF processing completes | Project student | Feedback notification preference | +| Weekly staff summary | `app/models/unit_role.rb:207` – staff weekly summary | Staff member represented by the unit role | Existing summary eligibility/preference logic | +| Single notification email | `app/services/notification_service.rb:69` – `NotificationService.notify` accepts an event for delivery | Notification user | Gated by the preference mapped from the notification category | +| Task PDF failed | `app/sidekiq/accept_submission_job.rb:36` – submitted task PDF conversion fails | Project student | `receive_task_notifications` | +| Submission processing error | `app/sidekiq/accept_submission_job.rb:55` – submission processing raises an error and produces an error mail | Administrator/error recipient | None observed | +| Archive error | `app/sidekiq/archive_old_units_job.rb:22` – old-unit archive operation produces an error mail | Administrator/error recipient | None observed | +| D2L grade transfer result | `app/sidekiq/d2l_post_grades_job.rb:27` – D2L grade transfer completes | User who initiated the transfer | None observed | +| D2L grade transfer failure | `app/sidekiq/d2l_post_grades_job.rb:35` – D2L grade transfer fails | User who initiated the transfer | None observed | +| Communication email to student | `app/sidekiq/execute_communication_set_job.rb:162` – an active communication rule executes for matched students | Students matched by the communication rule | Rule conditions determine recipients | +| Communication email to staff | `app/sidekiq/execute_communication_set_job.rb:203` – a staff-email communication action executes | Tutors and/or convenors selected by the rule | Recipient groups are configured in the rule | +| Communication action log | `app/sidekiq/execute_communication_set_job.rb:326` – communication execution produces its action log | Convenors | Operational communication email | +| Tutor note | `app/sidekiq/notify_tutor_notes_job.rb:8` – tutor-note notification job runs | Specific recipient supplied to the job | No preference gate observed in this send path | +| PDF-generation error mail | `lib/tasks/generate_pdfs.rake:145` – PDF generation produces an error mail | Administrator/error recipient | None observed | +| Portfolio ready | `lib/tasks/generate_pdfs.rake:157` – portfolio generation succeeds | Project student | `receive_portfolio_notifications` | +| Portfolio failed | `lib/tasks/generate_pdfs.rake:159` – portfolio generation fails | Project student | `receive_portfolio_notifications` | +| Task PDF failed – maintenance | `lib/tasks/maintenance.rake:50` – maintenance PDF processing fails | Project student | Existing task-notification guard in the maintenance flow | +| Maintenance error mail | `lib/tasks/maintenance.rake:72` – maintenance operation produces an error mail | Administrator/error recipient | None observed | +| Overseer assessment failed | `lib/tasks/overseer_notifications.rake:14` – failed Overseer assessments are grouped for notification | Affected project student | Existing Overseer notification flow | + +## Mailers already present + +The API currently contains the following relevant mailers: + +- `CommunicationsMailer` – sends configurable communication emails and + communication action logs. +- `D2lResultMailer` – reports D2L grade-transfer results. +- `ErrorLogMailer` – sends operational/error reports. +- `NotificationsMailer` – sends single event notifications and weekly student + and staff summaries. +- `PortfolioEvidenceMailer` – handles task PDF failure, task feedback ready, + Overseer assessment failure, portfolio ready and portfolio failed emails. +- `TutorNoteMailer` – sends tutor-note notifications to a supplied recipient. + +`ConvenorContactMailer#request_project_membership` and +`PortfolioEvidenceMailer#task_pdf_ready_message` also exist, but no active +`.deliver`/`.deliver_now` send site was found for either during this audit. +They are therefore not counted among the 21 current email send sites. + +## Existing web communication subsystem + +The web application already contains a unit communications editor under: + +`src/app/units/states/edit/directives/unit-communications-editor/` + +This is an existing communication system rather than a placeholder for future +notification work. + +A convenor can configure communication rules with conditions and actions. +Available actions include: + +- Send email to student +- Send email to staff +- Add a task comment +- Change target grade + +Student emails support a configurable subject and body. Staff emails also +support configurable subject/body content and can target tutors, convenors, or +both. + +Communication rules can filter students using existing conditions including +task status, target grade, login status, special consideration, tutorial, +tutorial stream and campus. + +The subsystem also supports scheduled communication sets. A schedule can run +once or recur daily, weekly or monthly. It supports a start week/day/time, +timezone, recurrence interval, repeat count and optional end date. + +When a communication set executes, matched students can receive the configured +actions. The execution logic also prevents a student matched by an earlier rule +in the same set from being processed again by a later rule. + +## Duplicate-email risks + +The existing communications subsystem is the largest duplication risk for v2. +OnTrack can already send configurable email to students and staff, including +scheduled and recurring communication across a unit. A new event should not +reimplement this behaviour without first deciding whether the event belongs in +the existing communication-rule system. + +Portfolio events are another clear overlap. OnTrack already emails a student +when portfolio generation succeeds and when it fails. A v2 portfolio event that +also sends email could therefore double-mail the same student. + +Task and feedback events must also be checked against +`PortfolioEvidenceMailer`, weekly summaries and the communication-rule system. +Existing task-PDF failures and feedback-ready messages already reach students. + +Tutor-note and task-comment work also require care. Tutor notes already have a +direct email path, while the communication editor can create task comments. +New notification hooks around these actions should establish whether the +existing email is being replaced, supplemented, or intentionally left alone. + +`NotificationService` introduces another duplication boundary. Events routed +through it can generate a notification email after the relevant notification +preference check. An event must not also retain an independent legacy email +unless two messages are explicitly intended. + +## Recipient and preference observations + +Existing emails do not use one common preference mechanism. + +Some student-facing flows explicitly check preferences, including task, +feedback and portfolio notification settings. `NotificationService` also maps +notification categories to user preferences before delivery. + +Other emails are operational messages or direct workflow results and have no +user preference gate in their send path. These include administrator error +messages and D2L transfer results. + +The communication-rule subsystem determines recipients from rule conditions +and configured recipient groups rather than the v2 notification preference +mapping. + +This distinction must be preserved when an existing email is migrated or +connected to a v2 event. Adding a second preference check without understanding +the legacy behaviour could either suppress a required operational message or +allow duplicate user-facing mail. + +## Conclusion + +OnTrack already has substantial email functionality in both repositories. + +In particular: + +1. Students are already emailed when their portfolio is ready or generation + fails. +2. Students already receive task, feedback, summary and Overseer-related + emails in existing flows. +3. Staff already receive weekly summaries and can be targeted through the unit + communications system. +4. Convenors can already configure and schedule email to a unit without any + new v2 notification feature. +5. The communication subsystem supports both student and staff email and + recurring schedules. +6. New v2 events must be checked against these send paths before another email + channel is added. + +The safest rule for subsequent event tickets is therefore: before adding an +email delivery path, check this audit and the existing communication subsystem +to determine whether OnTrack already sends an equivalent message. \ No newline at end of file From a70207dbbfd0288ea8955e192136a227ba2ab8f7 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Fri, 14 Aug 2026 22:56:12 +1000 Subject: [PATCH 043/247] docs(notifications): add the local push testing guide --- docs/notifications/testing-push-locally.md | 489 +++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 docs/notifications/testing-push-locally.md diff --git a/docs/notifications/testing-push-locally.md b/docs/notifications/testing-push-locally.md new file mode 100644 index 0000000000..0211ea1d23 --- /dev/null +++ b/docs/notifications/testing-push-locally.md @@ -0,0 +1,489 @@ +# Testing push notifications locally + +MN-D03. How to get a push notification to arrive on your own machine, and then on +a real phone. + +This does not cover generating VAPID keys, registering a browser by hand, or what +the payload looks like. That is all in +[`push-setup.md`](./push-setup.md), in this same folder. Read that first. This document is only about the two +things it does not answer: why push works on `localhost` with no HTTPS, and why it +stops working the moment you point a phone at your laptop. + +Every section is marked **Verified** or **Not tested**. Read those marks. The +tunnel half of this guide has not been walked end to end by anyone yet, so treat +section 3 as a route rather than a proven path and correct it as you go. + +--- + +## 0. Push does nothing without MN-F03 + +**Verified** — read from web#4, `push/enable-service-worker-in-dev`, merged into +`feature/notifications` in `doubtfire-web` on 8 Aug 2026. + +A service worker is the thing that receives a push. Before MN-F03, `angular.json` +set `"serviceWorker": "ngsw-config.json"` only on the `production` build +configuration, so a development build never generated `ngsw-worker.js` at all. +The browser had nothing to receive a push with, and the failure was silent. + +MN-F03 changed two files: + +```diff + "development": { + "optimization": false, + "extractLicenses": false, +- "sourceMap": true ++ "sourceMap": true, ++ "serviceWorker": "ngsw-config.json" + }, +``` + +```diff + ServiceWorkerModule.register('ngsw-worker.js', { +- enabled: environment.production, ++ enabled: environment.production || environment.enableServiceWorker, + registrationStrategy: () => interval(6000).pipe(take(1)), + }), +``` + +plus `enableServiceWorker: true` in `src/environments/environment.ts`. + +**If you are on a branch that does not have MN-F03, nothing in this guide will +work and you will get no error telling you why.** Check before you start: + + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4200/ngsw-worker.js + +200 means you have it. 404 means you do not. Full detail, including the six second +registration delay and the `$NODE_ENV` trap, is in +`docs/service-worker.md` in the `doubtfire-web` repo, on `feature/notifications`. + +--- + +## 1. `http://localhost` is a secure context, so localhost needs no HTTPS + +**Verified** — behaviour recorded in `doubtfire-web/docs/service-worker.md` on +2026-08-02, where a push sent from the api arrived as a desktop notification on +`http://localhost:4200`. I did not re-run it for this guide. + +Service workers and the Push API are restricted to secure contexts. People read +that as "I need HTTPS" and go and generate a self-signed certificate, or set up +mkcert, or ask why the dev stack does not do TLS. **None of that is necessary.** + +The rule is not "must be HTTPS". It is "must be a *potentially trustworthy +origin*", and loopback addresses are on that list by definition. So all of these +are secure contexts: + +- `http://localhost:4200` +- `http://localhost` on any port +- `http://127.0.0.1:4200` +- `http://[::1]:4200` + +Which means the ordinary dev stack, `ng serve` on port 4200 over plain HTTP, is +already good enough to register a service worker, subscribe to push, and receive +a push. Do this part first. It is the fast loop, and if push does not work here it +will not work anywhere else either. + +Order of work: + +1. `curl` `/ngsw-worker.js` and get a 200 (section 0). +2. Follow `push-setup.md` to confirm the keys are loaded and subscribe the browser. +3. Trigger an event, get a notification on your desktop. + +Only once that works should you go anywhere near a tunnel. + +--- + +## 2. The trap: a phone on your wifi is not a secure context + +**Verified** — the underlying rule is the same secure-context rule as above. Not +tested with a physical phone. + +`angular.json` sets the dev server to `"host": "0.0.0.0"`, so `ng serve` listens +on every interface, not just loopback. Your laptop's LAN address works. You can +type `http://192.168.1.42:4200` into a phone on the same wifi and the OnTrack app +loads, logs in, and behaves completely normally. + +**And push will not work, and nothing will tell you why.** + +`192.168.1.42` is not a loopback address. It is a plain HTTP origin like any +other, so it is not a secure context, so `navigator.serviceWorker` is not even +defined on that page. Practically: + +- No service worker registers. +- `SwPush.isEnabled` is `false`, so MN-C01's opt-in button reports "not + supported" rather than an error. +- `Notification.requestPermission()` may still work, which makes it look like + permissions are the problem when they are not. +- Nothing appears in the console. There is no exception, no warning, no failed + request. The feature is just absent. + +This is the evening-costing one. The symptom is "it works on my laptop but not on +my phone", and the instinct is to go and debug the subscription code, the VAPID +key, the api logs, notification permissions on the phone. All of that is fine. The +page is simply not a secure context. + +Quick check, in the phone's browser console or as a bookmarklet: + +```js +console.log(window.isSecureContext, 'serviceWorker' in navigator); +``` + +`false false` on the LAN address, `true true` through a tunnel. If you only take +one thing from this document, take that line. + +**Testing on a real phone therefore needs real HTTPS, which means a tunnel.** + +### iOS is a second trap on top of the first + +**Not tested.** Documented Safari behaviour, included because it will come up. + +Safari on iOS only supports Web Push for web apps that have been added to the +Home Screen. Opening the tunnel URL in Safari and expecting a push will fail even +over HTTPS. The user has to Share → Add to Home Screen and open it from there. +Android Chrome has no such restriction. If you are picking a phone to test with, +pick Android. + +--- + +## 3. Tunnel setup with cloudflared + +**Not tested.** `cloudflared` is not installed on this machine and I have not run +any of this. The steps below are written from the tool's documented behaviour and +from configuration I did verify in our repos (each config change is marked +separately). Treat the sequence as a first draft that needs someone to walk it. + +I picked `cloudflared` over `ngrok` because a quick tunnel needs no account, no +signup and no authtoken. `ngrok` now requires an account before it will forward +anything. One tool, done properly, rather than two done badly. + +### Why one tunnel is enough + +**Verified** — read from `doubtfire-web/src/app/config/constants/hostUrl.ts` and +`doubtfire-web/proxy.conf.json`. + +The obvious worry is that you need two tunnels, one for the web app on 4200 and +one for the api on 3000, and that the phone would load an HTTPS page that then +tries to call `http://localhost:3000` and gets blocked as mixed content. + +That does not happen, because of two things already in the repo: + +```ts +// src/app/config/constants/hostUrl.ts +const HOST_URL: string = `${window.location.protocol}//${window.location.hostname}${window.location.port ? ':' + window.location.port : ''}`; +``` + +The app derives its api base URL from wherever the page was loaded from. It is not +hardcoded. And `package.json` runs `ng serve ... --proxy-config proxy.conf.json`, +which forwards `/api` to the api container: + +```json +{ "/api": { "target": "http://localhost:3000", "secure": false } } +``` + +So the browser only ever talks to one origin. Tunnel port 4200 and the api comes +along with it. **Do not tunnel port 3000 as well.** It will not help and it gives +you a second hostname to get wrong. + +### Step 1 — install cloudflared + + brew install cloudflared + +### Step 2 — have the stack running on localhost first + +Section 1. If push does not work on `http://localhost:4200`, a tunnel will not fix +it, it will just add a second thing that can be broken. + +### Step 3 — start the tunnel + + cloudflared tunnel --url http://localhost:4200 + +It prints a hostname that looks like: + + https://random-words-here.trycloudflare.com + +That hostname is new every time you restart the tunnel. Which matters, because +both config changes below name it, so **you will be editing config every time you +restart the tunnel.** Leave it running. + +### Step 4 — let the Angular dev server answer to that hostname + +**Verified** that this option exists and is spelled this way — read from +`node_modules/@angular/build/src/builders/dev-server/schema.json` at version +22.0.4. Not verified that the tunnel then works. + +Vite, which is what `@angular/build:dev-server` runs on, rejects requests whose +`Host` header is not in its allowlist. Without this you get a Vite "Blocked +request" page through the tunnel instead of the app. + +In `angular.json`, under `projects.doubtfire.architect.serve.options`: + +```json +"serve": { + "builder": "@angular/build:dev-server", + "options": { + "buildTarget": "doubtfire:build", + "port": 4200, + "host": "0.0.0.0", + "allowedHosts": ["random-words-here.trycloudflare.com"] + }, +``` + +The schema also accepts `"allowedHosts": true` to allow everything. Its own +description calls that "not recommended and a security risk", which is fair, since +your dev server is on the public internet for as long as the tunnel is up. Use it +if you are restarting the tunnel constantly and are sick of editing this file, but +do not commit it. + +**Do not commit any of this.** It is a hostname that will not exist tomorrow. + +### Step 5 — let the api answer to that hostname + +**Verified** — I reproduced the failure directly against the running api +container: + +``` +$ curl -s -H "Host: probe-test.trycloudflare.com" http://localhost:3000/api/settings +Blocked hosts: probe-test.trycloudflare.com +To allow requests to these hosts, make sure they are valid hostnames (containing +only numbers, letters, dashes and dots), then add the following to your +environment configuration: +config.hosts << "probe-test.trycloudflare.com" +``` + +The same request with the default `Host` returns 200. + +This is Rails 8 Host Authorization, not CORS. In development Rails allows loopback +addresses, any raw IP, and anything ending in `.localhost` or `.test`. A +`trycloudflare.com` hostname is none of those, so the api rejects it before any +of our code runs. + +The fix that needs no code change, in `doubtfire-deploy/development/docker-compose.yml` +under `doubtfire-api.environment`: + +```yaml + RAILS_DEVELOPMENT_HOSTS: 'random-words-here.trycloudflare.com' +``` + +**Verified** that Rails reads this variable and splits it on commas — railties +8.0.2, `lib/rails/application/configuration.rb`. Comma separate if you need more +than one. + +Then recreate the container. `restart` does not pick up new environment +variables, same as with the VAPID keys: + + docker compose -f docker-compose.yml -f docker-compose.local-paths.yml up -d doubtfire-api + +Confirm it took: + + curl -s -o /dev/null -w "%{http_code}\n" \ + -H "Host: random-words-here.trycloudflare.com" \ + http://localhost:3000/api/settings + +### About CORS, which is not your problem + +**Verified** — read from `doubtfire-api/config/application.rb:287`. + + config.middleware.insert_before Warden::Manager, Rack::Cors do + allow do + origins '*' + resource '*', headers: :any, methods: %i(get post put delete options) + end + end + +Origins is already `*`. **There is no CORS change to make for a tunnel.** And +because of the dev server proxy in step 3, the api calls are same-origin anyway, +so CORS is not even in play. If you are looking at a CORS error you have found a +different bug. The api-side change you actually need is `RAILS_DEVELOPMENT_HOSTS` +above. + +### An alternative to step 5 that I have not tried + +**Not tested.** Setting `"changeOrigin": true` on the `/api` entry in +`proxy.conf.json` should make the proxy rewrite the `Host` header to +`localhost:3000` before forwarding, so Rails never sees the tunnel hostname and +`RAILS_DEVELOPMENT_HOSTS` becomes unnecessary. That would survive tunnel restarts, +which is the appeal. + +I did not test it, and I did not confirm what the proxy's default actually is. +`RAILS_DEVELOPMENT_HOSTS` is the one I verified fails and can be made to pass, so +that is what step 5 says. If you try `changeOrigin`, note that in the Docker stack +`doubtfire-deploy/development/docker-compose.local-paths.yml` mounts +`proxy.conf.docker.json` over the repo's `proxy.conf.json` read-only, so you have +to edit the deploy repo's copy, not the web repo's. + +### Step 6 — open it on the phone + +Open the printed `https://...trycloudflare.com` URL on the phone. Check the secure +context first, before anything else: + +```js +window.isSecureContext && 'serviceWorker' in navigator +``` + +Then sign in, wait out the six second service worker registration delay, and use +MN-C01's opt-in button. Confirm the subscription actually landed: + + docker exec doubtfire-api bundle exec rails runner \ + 'puts PushSubscription.all.map { |s| "#{s.user.username} #{s.endpoint[0, 60]}" }' + +The endpoint for a phone will be an FCM URL rather than whatever your desktop +browser uses. That is how you know you are looking at the phone's row and not the +one you made on localhost earlier. Then trigger an event as another user and +watch. + +### Things that will probably go wrong + +**Not tested.** Written from what the configuration implies, not from experience. + +- **Vite's HMR websocket may not survive the tunnel.** The page still loads, live + reload just stops. Not worth fixing for a push test, reload by hand. +- **The tunnel hostname changes on every restart**, and both step 4 and step 5 + name it. If push worked yesterday and does not today, check that first. +- **A quick tunnel is public.** Anyone with the URL reaches your dev stack with + its throwaway VAPID keys and seeded database. Stop it when you are done. +- **You will have two subscriptions for your user**, one from the desktop test and + one from the phone. Both get pushed. That is correct behaviour, not a bug. + +--- + +## 4. Clearing a stuck service worker + +**Not tested** in Firefox. The Chrome console snippet is taken from +`doubtfire-web/docs/service-worker.md`, which is where the fuller writeup of the +service worker's caching side effects lives. + +A service worker caches the whole app bundle and keeps serving it. The symptom is +that you change code, reload, and still see the old code. A hard reload does not +help, because the worker still intercepts the request. + +### Both browsers, from the console + +Works in Chrome and Firefox. Faster than the UI and the one to reach for at 1am: + +```js +(await navigator.serviceWorker.getRegistrations()).forEach((r) => r.unregister()); +const keys = await caches.keys(); +await Promise.all(keys.map((k) => caches.delete(k))); +location.reload(); +``` + +### Chrome, through dev tools + +1. F12 → **Application** → **Service Workers**. +2. **Unregister** next to `ngsw-worker.js`. +3. **Application** → **Storage** → **Clear site data**. +4. Reload. + +While you are actively working on the app, **Application → Service Workers → +Bypass for network** stops the worker serving cached responses without +unregistering it. That is usually what you want during normal development. It is a +per-devtools-session setting and it resets when you close dev tools. + +`chrome://serviceworker-internals` lists every registration in the profile and +will unregister them, which is the one to use when a worker is stuck on an origin +you no longer have open. + +### Firefox, through dev tools + +1. F12 → **Application** → **Service Workers**. +2. **Unregister**. +3. **Storage** → right click the origin → **Delete All**. +4. Reload. + +`about:debugging#/runtime/this-firefox` is the equivalent of Chrome's +`serviceworker-internals` and has **Unregister** buttons per worker. + +Firefox private windows do not run service workers at all, so push cannot work +there. Do not use one to test. + +--- + +## 5. Resetting notification permission + +**Not tested.** Written from the current browser UIs. Someone should walk these +and correct them. + +Permission is per origin, and once denied the browser will not ask again. The +opt-in button will report "blocked" forever and no amount of clicking will +prompt. You have to reset it by hand. Everyone denies it by accident once. + +Check where you stand, in the console: + +```js +Notification.permission // "default" | "granted" | "denied" +``` + +`default` means you will be prompted. `denied` means you will not. + +### Chrome + +Fastest: click the icon at the left of the address bar (the tune or lock icon), +find **Notifications**, set it back to **Ask (default)**. Reload. + +Or `chrome://settings/content/notifications`, find the origin under **Not allowed +to send notifications**, and remove it. Whole-origin nuke, which also clears the +service worker and everything else, is **Clear site data** in the same panel. + +Two things that are not the same as the browser permission and get confused with +it: + +- **macOS System Settings → Notifications → Google Chrome.** If Chrome itself is + not allowed to post notifications, the browser permission can be `granted` and + the push can arrive and you still see nothing. `push-setup.md` calls this out + too. Check it once, then stop thinking about it. +- **Focus / Do Not Disturb.** Same result, notifications delivered silently to + Notification Centre. + +### Firefox + +Click the padlock in the address bar → **Clear cookies and site data**, or expand +**Connection secure** → **More information** → **Permissions**, find **Receive +Notifications**, and untick **Use Default** then set it back. + +Or `about:preferences#privacy` → **Permissions** → **Notifications** → +**Settings**, find the origin, **Remove Website**. Reload. + +Check `about:preferences#privacy` → **Notifications** → **Settings** for **Block +new requests asking to allow notifications** as well. If that is ticked, nothing +will ever prompt and the state will read `denied` on every site. + +### Android Chrome + +Site permissions are under the padlock → **Permissions** → **Notifications**. But +also check Android **Settings → Apps → Chrome → Notifications**, because if +Chrome as an app is blocked at the OS level then no site inside it can post +anything, and the in-page permission will still say `granted`. + +--- + +## Verification status, all in one place + +| Section | Status | +|---|---| +| MN-F03 is required, and what it changed | **Verified.** Read from the web#4 diff, merged 8 Aug 2026. | +| `http://localhost` is a secure context | **Verified.** Recorded working in `doubtfire-web/docs/service-worker.md`, 2026-08-02. Not re-run here. | +| A LAN address is not a secure context | **Verified on a physical phone.** iPhone 16 / iOS 26.6, 13 Aug 2026, MN-Q03. The phone reached the api over the LAN address but the opt-in button was disabled with "This browser does not support push notifications". | +| iOS needs Add to Home Screen | **Not tested.** Documented Safari behaviour. | +| One tunnel is enough, because of `hostUrl.ts` + the dev server proxy | Config **verified** by reading it. The conclusion is an inference, **not tested**. | +| `cloudflared` install and tunnel steps | **Not tested.** `cloudflared` is not installed on this machine. | +| `allowedHosts` is a real dev server option | **Verified** against `@angular/build` 22.0.4's schema. Effect through a tunnel **not tested**. | +| Rails blocks a tunnel hostname | **Verified.** Reproduced with `curl -H "Host: ..."` against the running api. | +| `RAILS_DEVELOPMENT_HOSTS` is the variable Rails reads | **Verified** in railties 8.0.2 source. Not tested end to end through a tunnel. | +| No CORS change is needed | **Verified.** `origins '*'` at `config/application.rb:287`. | +| `changeOrigin` as an alternative | **Not tested.** Offered as a lead, not a step. | +| Clearing a service worker | Console snippet from `service-worker.md`. Chrome and Firefox UI paths **not tested**. | +| Resetting notification permission | **Not tested**, all browsers. | + +Section 2 has now been walked. MN-Q03 hit exactly the failure this guide +predicts, on an iPhone 16 running iOS 26.6 on 13 Aug 2026. The phone reached the +api fine over the LAN address and the opt-in button was still disabled, which is +the browser refusing to expose the push API outside a secure context. So the +rule holds on real hardware and not just on paper. + +Section 3, the tunnel, is still unwalked. Nobody has yet run `cloudflared` end +to end and got a push onto a phone. That is what MN-Q02 and the retest of MN-Q03 +are for, and a screenshot of a notification arriving on a real lock screen is +the deliverable that closes them. + +If you are the first person through section 3, correct this document as you go +rather than working around it. Every step in there was reasoned from config +rather than executed, and the marks in the table above say which is which. From a11b4f26c9ea82431a0c53fd1dcdd9b2d0a51d36 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Fri, 14 Aug 2026 23:00:46 +1000 Subject: [PATCH 044/247] docs(notifications): add the notifications contribution guide --- docs/notifications/CONTRIBUTING.md | 382 +++++++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 docs/notifications/CONTRIBUTING.md diff --git a/docs/notifications/CONTRIBUTING.md b/docs/notifications/CONTRIBUTING.md new file mode 100644 index 0000000000..3340695e5c --- /dev/null +++ b/docs/notifications/CONTRIBUTING.md @@ -0,0 +1,382 @@ +# How to contribute to notifications + +For everyone working on Email Notifications or Mobile Notifications. + +Read this once. It is short. Most of it is here because somebody already lost a +day to it. + +This is not the `CONTRIBUTING.md` at the root of `doubtfire-api`. That one is +upstream Doubtfire's and it is not ours. + +--- + +## The three rules that matter most + +1. **Branch off `feature/notifications`. Open your pull request back into + `feature/notifications`.** Never `11.0.x`. Never `development`. +2. **Paste your pull request link into your Planner ticket.** If you skip this, + your work does not get counted. There is no automatic backup. +3. **If you are stuck for more than about an hour, say so.** Post where you got + stuck. That is not failure, that is the job. Sitting silently stuck helps + nobody and it costs you the ticket. + +--- + +## Before you start a ticket + +Tick the first checklist item on the ticket: **"Confirmed I have started. My +branch name is ______"** and fill in the branch name. + +This is how we know a ticket is being worked on. If that box is empty, anyone +can take the ticket. Ten seconds of your time saves someone else duplicating +your work. + +--- + +## Setting up, and the four things that go wrong + +Full instructions are in `doubtfire-deploy/RUNNING-LOCALLY.md`. Read that first. +These are the failures that are not in it. + +**You are probably pointed at the wrong remote.** Our work is in the +`ontrack-features-t2-2026` organisation. It is not on `thoth-tech` and it is not +on `doubtfire-lms`. If `git fetch` cannot find `feature/notifications`, this is +why. + +``` +git remote set-url origin https://github.com/ontrack-features-t2-2026/.git +git fetch origin +``` + +`doubtfire-api` and `doubtfire-web` sit on `feature/notifications`. +`doubtfire-deploy` sits on `11.0.x` and has no notifications branch. + +**A push that fails with 403 is an access problem, not a git problem.** Being a +member of the organisation gives you read only. Write comes from the +`ontrack-contributors` team. Ask the lead and it takes one minute to fix. + +**On Windows, do not put the database on a bind mount.** MariaDB cannot reliably +rename a table across the Windows host share and `db:populate` dies with +`Tablespace is missing for a table`. This is fixed on `11.0.x` in deploy, using a +named `db_data` volume. If you hand edited your compose file to work around it, +undo the edit and pull instead. + +**The branch name your clone shows you can be a lie.** On macOS the filesystem +is case insensitive, so an inherited `Feature/` directory in `.git/refs` swallows +later lowercase `feature/*` refs and `git branch -a` will show you a capitalised +branch that does not exist on the server. Never read a branch name off +`git branch -a` for a pull request. Use `git ls-remote --heads origin`. + +--- + +## Which repository + +Every ticket says which repository it is in. + +| Repo | What it is | +|---|---| +| `doubtfire-api` | The backend. Ruby on Rails | +| `doubtfire-web` | The frontend. Angular | +| `doubtfire-deploy` | Docker and configuration | + +If your ticket says `none`, there is no code. You are writing a document and +attaching it to the ticket. + +--- + +## Branches + +Integration branch: **`feature/notifications`** + +Your branch is named on the ticket. It looks like `email/task-comment` or +`push/opt-in`. + +``` +git checkout feature/notifications +git pull origin feature/notifications +git checkout -b email/task-comment +``` + +Do the work, then: + +``` +git add +git commit -m "feat(notifications): email on new task comment" +git push -u origin email/task-comment +``` + +**Never create a branch underneath a name that is already a branch.** Git cannot +hold both a branch and a folder at the same path and it fails with +`cannot lock ref`. Concretely: no work branch may be named +`feature/notifications/`. Work branches live under `email/` and +`push/`, which can never collide with the integration branch. + +--- + +## Commits + +Format: `type(scope): short summary in the present tense` + +``` +feat(notifications): email on new task comment +fix(profile): stop resetting notification preferences on edit +docs(notifications): audit existing email send sites +test(notifications): cover preference gating +chore(deploy): add mail catcher to local dev stack +``` + +Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`. Keep the summary under +about 50 characters. Use the scope `notifications` unless you are genuinely +touching something else. Every ticket has its commit message already written on +it, so you can copy it. + +--- + +## Pull requests + +Open it against **`feature/notifications`**. Double-check this. GitHub often +defaults to the wrong branch and it is the single most common mistake. +**Check the base repository, not just the branch name.** It must read +`ontrack-features-t2-2026/...`. If it reads `doubtfire-lms/...`, change it. The +upstream maintainer has a branch called `feature/notifications` too, two hops up +the fork network, so the branch name on its own no longer tells you where you +are pointing. + +Your PR description must include: + +``` +Ticket: EN-E01 + +Built against: + doubtfire-api feature/notifications + doubtfire-web feature/notifications + doubtfire-deploy 11.0.x + +What this does: + + +How I tested it: + +``` + +**Reviewers are told to reject pull requests that leave out the built-against +block.** Get each sha with `git rev-parse --short HEAD` in that repository. Yes, +all three, even if you only touched one. It is how a reviewer reproduces what you +saw. + +Keep pull requests small. Everything that merged last trimester was between +about 30 and 130 lines across fewer than ten files. Large pull requests stall, +and they stall for weeks rather than days. + +--- + +## Review + +How many approvals you need depends on what you touched. + +**One approval** if your change only adds new files of your own plus a few lines +in a model. Most event tickets are this. + +**Two approvals** if you touched any of: + +- a database migration +- `db/schema.rb` +- `NotificationService` or `PushNotificationService` +- configuration, a manifest, or the Gemfile +- a file another open ticket is also touching + +If you are not sure, ask. Guessing low wastes a reviewer's time. Guessing high +costs you nothing. + +The lead merges. Do not merge your own pull request, and note that GitHub will +not let you approve it either. + +**There is no CI.** Nothing on GitHub runs either test suite. No status check +will ever go green or red on your pull request, so a merge button that looks +happy tells you nothing about whether your code works. The human read is the +only gate we have. Put your real test output in the pull request body so a +reviewer has something to check rather than a promise. + +**Two approvals is our rule, not GitHub's.** The ruleset enforces one. Do not +treat an available merge button as evidence the rule was met. + +**If you stack your branch on somebody else's unmerged branch, the approval gate +quietly disappears.** Rulesets cover `feature/notifications`, not whatever branch +was cut yesterday. So a pull request targeting a teammate's branch can merge with +zero approvals. Stacking is sometimes the right thing to do, just tell the lead +when you do it, and retarget to `feature/notifications` once the branch below you +lands. + +--- + +## Keeping up to date + +Other people are merging into `feature/notifications` while you work. Before you +open your pull request: + +``` +git checkout feature/notifications +git pull origin feature/notifications +git checkout +git merge feature/notifications +``` + +Fix any conflicts, then push. If a conflict looks frightening, **stop and ask.** +Do not force push. Do not delete files to make the conflict go away. Someone +will help you in five minutes. + +--- + +## Two files that cause conflicts, and how we avoid them + +**`db/schema.rb`.** This is rebuilt automatically every time anyone adds a +migration, and two branches with migrations will always conflict. Only a couple +of tickets have a migration and they are all held by the lead. **If your ticket +does not mention a migration and you find yourself writing one, stop and ask.** +You are probably solving the wrong problem. + +**Event documentation.** Every event gets its own file at +`docs/notifications/events/.md`. Never add to a shared list. If +everyone edited one file, every event ticket would conflict with every other one. + +One more that is not a file. **Leave a newline at the end of every file you +touch.** Prettier enforces it on the web side, and a missing final newline turns +the last line of a shared file into a conflict against every other open pull +request. + +--- + +## The one domain rule: notifications send inline + +`NotificationService.notify` sends the email and the push **synchronously**, on +the request thread. There is no worker process running in the dev stack, which +is exactly why it works that way. `app/services/notification_service.rb` explains +the reasoning at the top of the file. + +The consequence matters more than the mechanism. **Any event that can address a +whole cohort sends one email plus one push per person, inside a single web +request.** A group CSV import, a task definition added to a unit, a bulk marking +run. Every one of those is a timeout in production and a very long request in +development. + +So before you wire an event to a hook, ask who it reaches when the hook fires in +the worst case, not the normal case. Three separate tickets have hit this +independently. If the answer is "everyone in the unit", stop and talk to the lead +before you build it. Batching is EN-F03 and it is not done yet. + +Two related habits worth having: + +- **Never notify somebody about their own action.** Check the actor against the + recipient. +- **Look at every caller of the method you are hooking, not just the obvious + one.** `add_member` looks like a student joining a group. It is also called by + tutorial changes, enrolment deletion and CSV import. + +--- + +## Documentation + +All notification documentation lives in **one** place: +`doubtfire-api/docs/notifications/`. Do not start a new folder, and do not put it +in `doubtfire-web`. + +Naming: lowercase, hyphenated, no dates in the filename, one file per subject. +`push-setup.md`, not `PushSetup_2026-08-14.md`. + +| What you are writing | Where it goes | +|---|---| +| An event | `docs/notifications/events/.md` | +| Anything else | `docs/notifications/.md` | + +**For an event, copy `docs/notifications/events/_template.md` and fill in the +eight field table.** It is not optional formatting. The table is what lets +somebody read the recipient and the preference gate without opening the code, +and it is what the security review tickets read. + +Worked examples to copy rather than invent: + +- `docs/notifications/events/task_comment_created.md` — the model event doc +- `docs/notifications/events/_template.md` — the eight fields +- `docs/notifications/push-setup.md` — VAPID keys and payloads +- `docs/notifications/testing-push-locally.md` — read this before you try to + test push on a phone + +**Push does not work on a phone over your LAN address.** A phone on your wifi +hitting `http://192.168.x.x:4200` is not a secure context, so the browser hides +the push API entirely and the opt-in button greys out. `localhost` is fine +without HTTPS. A phone is not localhost. You need a tunnel, and +`testing-push-locally.md` has the commands. On iOS there is a second step, you +have to Add to Home Screen and open it from the icon. + +If somebody asks you a question this page does not answer, the answer goes in +here, not just in a reply. + +--- + +## Tests + +Write them. Every code ticket has its tests in the steps. + +- **API:** Minitest, in `test/`, mirroring the `app/` path. So a test for + `app/models/task.rb` goes in `test/models/`. Run inside the container, never + on your own machine. +- **Web:** vitest, in `.spec.ts` beside the component. + +Every Grape endpoint gets a test. Every new Angular component gets a `.spec.ts`. + +The `.rspec` file at the root of the api repository is **dead configuration.** +Ignore it. This project does not use RSpec, and the handover document that says +it does is a trimester out of date. The same document says Angular 17 and Karma. +It is Angular 22 and vitest. + +Development mail is written to files, not sent. It lands in +`doubtfire-deploy/data/tmp/mails/`, **not** `doubtfire-api/tmp/mails` as the +comment in `config/environments/development.rb` claims. The container mounts +`../data/tmp` over `/doubtfire/tmp`, so the comment is wrong under Docker. Mailpit +on port 8025 is the easier way to look at them. + +--- + +## Where things live + +| What | Where | +|---|---| +| Tickets | Microsoft Planner | +| Code | GitHub, `ontrack-features-t2-2026` | +| Evidence and documents | Attached to your Planner ticket | +| Notification documentation | `doubtfire-api/docs/notifications/` | +| How to run the app | `doubtfire-deploy/RUNNING-LOCALLY.md` | +| How to test push on a phone | `docs/notifications/testing-push-locally.md` | + +--- + +## Where your work ends up + +``` +your branch -> feature/notifications lead merges +feature/notifications -> thoth-tech Feature/Notifications Brian Dang merges +thoth-tech -> doubtfire-lms 11.0.x definition of done +``` + +`thoth-tech` has no `Feature/Notifications` branch yet. It has to be created +there before the second hop can happen, and that has been asked for. + +So a pull request you open is two merges away from the real OnTrack project. +That is worth knowing when you decide how much care to put into it. + +--- + +## If you are stuck + +Post in the team channel with: + +1. Your ticket ID +2. What you were trying to do +3. The exact error text, copied and pasted, not described +4. What you already tried + +Asking early is what a good contributor does. Nobody is judging you for it. +Going quiet for a week is the only thing that actually causes a problem. + +And if you are given a command you do not understand, say so before you run it. +That has already found one real bug in our Docker setup. From eb7dfb5a0f558136a1d55de44c53f50d5fbb4c3c Mon Sep 17 00:00:00 2001 From: Swyam Khare Date: Sat, 15 Aug 2026 01:37:48 +1000 Subject: [PATCH 045/247] feat(notifications): email when a task due date changes When a task definition's due date changes, this emails every enrolled student who has that task, one email each. The notification is raised from a new after_update callback in TaskDefinition, placed alongside the existing update_tii_group and reset_overdue_tasks callbacks. All three are guarded by saved_change_to_due_date?, so the notification only runs once the new date has been saved. notify_students_of_due_date_change walks the tasks the same way reset_overdue_tasks does, filtered to enrolled projects, and sends one notification to each student through NotificationService. The type is 'task', so it respects each student's receive_task_notifications setting, and the event is 'task_due_date_changed'. Each student is handled on its own, so one failure cannot stop the rest of the notifications and cannot roll back the save. The message names the task and unit but never the new date value. The dedicated templates are selected by NotificationsMailer#event_template_name, so the mailer is not changed. Files: - app/models/task_definition.rb: callback and fan-out - app/views/notifications_mailer/task_due_date_changed.html.erb and .text.erb - test/models/notification_due_date_test.rb (8 tests) - docs/notifications/events/task_due_date_changed.md (records the hook point) --- app/models/task_definition.rb | 32 +++++ .../task_due_date_changed.html.erb | 17 +++ .../task_due_date_changed.text.erb | 11 ++ .../events/task_due_date_changed.md | 93 ++++++++++++++ test/models/notification_due_date_test.rb | 116 ++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 app/views/notifications_mailer/task_due_date_changed.html.erb create mode 100644 app/views/notifications_mailer/task_due_date_changed.text.erb create mode 100644 docs/notifications/events/task_due_date_changed.md create mode 100644 test/models/notification_due_date_test.rb diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 7ef377811f..029cdaa969 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -63,6 +63,7 @@ def self.permissions after_update :update_tii_group, if: :saved_change_to_due_date? after_update :update_overdue_tasks_aip, if: :saved_change_to_assess_in_portfolio_only? after_update :reset_overdue_tasks, if: :saved_change_to_due_date? + after_update :notify_students_of_due_date_change, if: :saved_change_to_due_date? # Model associations belongs_to :unit, optional: false # Foreign key @@ -269,6 +270,37 @@ def reset_overdue_tasks end end + # Emails every student who has this task that its due date changed. A single + # due date change can affect a whole cohort, so this walks the tasks the same + # way reset_overdue_tasks does and sends one notification to each student. Only + # enrolled students are reached, and staff are never notified. The new date is + # left out of the message on purpose, because the email is meant to prompt the + # student to sign in rather than to carry the date itself. NotificationService + # handles the preference check and the delivery. + def notify_students_of_due_date_change + tasks.joins(:project).where(projects: { enrolled: true }).find_each do |task| + notify_student_of_due_date_change(task) + end + end + + # Each student is handled on its own so that one failure cannot stop the rest + # of the notifications and cannot roll back the due date change that triggered + # them. + def notify_student_of_due_date_change(task) + student = task.project.student + return if student.nil? + + NotificationService.notify( + user: student, + type: 'task', + event: 'task_due_date_changed', + message: "The due date for #{abbreviation} in #{unit.code} has changed.", + link: "/projects/#{task.project.id}/dashboard/#{abbreviation}" + ) + rescue StandardError => e + Rails.logger.error "Failed to raise task_due_date_changed notification for task #{task.id}: #{e.message}" + end + def move_files_on_abbreviation_change old_abbr = saved_change_to_abbreviation[0] # 0 is original abbreviation if File.exist? task_sheet_with_abbreviation(old_abbr, false) diff --git a/app/views/notifications_mailer/task_due_date_changed.html.erb b/app/views/notifications_mailer/task_due_date_changed.html.erb new file mode 100644 index 0000000000..8e8c044244 --- /dev/null +++ b/app/views/notifications_mailer/task_due_date_changed.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The new due date is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see it. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_due_date_changed.text.erb b/app/views/notifications_mailer/task_due_date_changed.text.erb new file mode 100644 index 0000000000..a9c61af991 --- /dev/null +++ b/app/views/notifications_mailer/task_due_date_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The new due date is not included in this email. Open the task in <%= @doubtfire_product_name %> to see it. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/docs/notifications/events/task_due_date_changed.md b/docs/notifications/events/task_due_date_changed.md new file mode 100644 index 0000000000..56207ec4f2 --- /dev/null +++ b/docs/notifications/events/task_due_date_changed.md @@ -0,0 +1,93 @@ +# Event: task_due_date_changed + +A staff member changes a task's due date. Every student who has that task is +told, one email each. Ticket EN (email when a task due date changes). + +Built the same way as the worked example in `task_comment_created.md`; read that +one first. + +## What it does + +A convenor changes a task definition's due date, and every enrolled student who +has that task gets an email telling them the date changed. This means they find +out from OnTrack instead of discovering the change themselves. + +## Hook point (confirmed) + +`app/models/task_definition.rb`. The due date lives on the **task definition**, +and two existing callbacks already fire on a due-date change: + + after_update :update_tii_group, if: :saved_change_to_due_date? # line 63 + after_update :reset_overdue_tasks, if: :saved_change_to_due_date? # line 65 + +This event adds a third callback in the same shape, right after them: + + after_update :notify_students_of_due_date_change, if: :saved_change_to_due_date? + +`saved_change_to_due_date?` only fires after the new due date has been saved, so +the notification never runs on a change that did not commit. + +## Reaching the affected students + +`notify_students_of_due_date_change` walks `tasks` the same way +`reset_overdue_tasks` (task_definition.rb:257) does, filtered to enrolled +projects, and sends one notification per student: + + NotificationService.notify( + user: task.project.student, + type: 'task', + event: 'task_due_date_changed', + message: "The due date for #{abbreviation} in #{unit.code} has changed.", + link: "/projects/#{task.project.id}/dashboard/#{abbreviation}" + ) + +A due date change can affect a whole cohort at once, so the fan-out is per task +and each send is isolated in `notify_student_of_due_date_change`. This way one +student failing cannot stop the rest and cannot roll back the due date save. + +## Fields + +| Field | Value | +|---|---| +| `type` | `task`, so each student's `receive_task_notifications` switch controls it | +| `event` | `task_due_date_changed` | +| `message` | Names the task and unit. Never the new (or old) due date value | +| `link` | `/projects//dashboard/` | + +## Templates + +- `app/views/notifications_mailer/task_due_date_changed.text.erb` +- `app/views/notifications_mailer/task_due_date_changed.html.erb` + +Picked up automatically by `NotificationsMailer#event_template_name`; the mailer +is not edited. + +## Three things to know before you copy this + +1. **Only enrolled students are reached.** The walk filters + `projects.enrolled = true`, so withdrawn students and staff are not emailed. + +2. **One student, one email.** A student has a single task per definition, so the + fan-out sends exactly one notification to each student rather than one per + event listener. + +3. **A notification must never break the save.** Each send is wrapped in a + `rescue StandardError` that logs and swallows, so changing a due date succeeds + even if a notification fails. + +## How to check it by hand + +1. As a convenor, change a task's due date. +2. Each enrolled student who has that task gets one email at + http://localhost:8025 (Mailpit). It names the task but not the new date. +3. A student in another unit, and a withdrawn student, get nothing. +4. Turn a student's task notifications off in their profile, change the date + again, and that student gets no email while the others still do. + +## Tests + +`test/models/notification_due_date_test.rb`. It covers the full fan-out, that a +student in another unit and a withdrawn student are not notified, the preference +switch, the new date staying out of the message, the link, and the +event-specific template. Run it on its own, because the test database is the +development database. See item 11 in `doubtfire-deploy/RUNNING-LOCALLY.md`. diff --git a/test/models/notification_due_date_test.rb b/test/models/notification_due_date_test.rb new file mode 100644 index 0000000000..cf8d256c3f --- /dev/null +++ b/test/models/notification_due_date_test.rb @@ -0,0 +1,116 @@ +require 'test_helper' +require 'minitest/mock' + +# EN: changing a task definition's due date emails every enrolled student who has +# that task, one each, and nobody else. +class NotificationDueDateTest < ActiveSupport::TestCase + setup do + @unit = FactoryBot.create(:unit) + @task_def = @unit.task_definitions.first + + # Tasks are created on demand, so materialise one per active project to give + # the fan-out records to walk (the same tasks a real cohort would have). + @unit.active_projects.each { |p| p.task_for_task_definition(@task_def) } + + @affected = @task_def.tasks + .joins(:project) + .where(projects: { enrolled: true }) + .map { |t| t.project.student } + .uniq + + ActionMailer::Base.deliveries.clear + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def change_due_date + @task_def.update!(due_date: @task_def.due_date + 1.week) + end + + def test_every_affected_student_is_emailed_once + assert @affected.size >= 2, 'guard: need several students for a meaningful fan-out' + + assert_difference 'Notification.count', @affected.size do + change_due_date + end + + assert_equal @affected.size, ActionMailer::Base.deliveries.count + recipients = ActionMailer::Base.deliveries.flat_map(&:to) + assert_equal @affected.map(&:email).sort, recipients.sort + + notification = Notification.recent_first.first + assert_equal 'task', notification.notification_type + assert_equal 'task_due_date_changed', notification.event + end + + def test_a_student_in_another_unit_is_not_notified + other = FactoryBot.create(:project) + + change_due_date + + assert_equal 0, Notification.where(user: other.student, event: 'task_due_date_changed').count + end + + def test_a_withdrawn_student_in_the_same_unit_is_not_notified + withdrawn = @unit.projects.find_by(enrolled: false) + assert_not_nil withdrawn, 'guard: the unit factory should include a withdrawn project' + withdrawn.task_for_task_definition(@task_def) # give them a task too + + change_due_date + + assert_equal 0, Notification.where(user: withdrawn.student, event: 'task_due_date_changed').count + end + + def test_it_respects_receive_task_notifications + opted_out = @affected.first + opted_out.update!(receive_task_notifications: false) + + change_due_date + + assert_equal 0, Notification.where(user: opted_out, event: 'task_due_date_changed').count + # everyone else still hears about it + assert_equal @affected.size - 1, ActionMailer::Base.deliveries.count + end + + def test_an_unrelated_update_sends_nothing + assert_no_difference 'Notification.count' do + @task_def.update!(description: 'A new description, unrelated to the due date.') + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_the_message_names_the_task_but_not_the_new_date + change_due_date + + notification = Notification.recent_first.first + new_date = @task_def.reload.due_date + + assert_includes notification.message, @task_def.abbreviation + assert_includes notification.message, @unit.code + assert_not_includes notification.message, new_date.strftime('%Y') + end + + def test_the_link_points_at_the_task_on_the_student_dashboard + change_due_date + + notification = Notification.recent_first.first + task = @task_def.tasks.detect { |t| t.project.student.id == notification.user_id } + + assert_equal( + "/projects/#{task.project.id}/dashboard/#{@task_def.abbreviation}", + notification.link + ) + end + + def test_the_event_specific_template_is_used + change_due_date + + assert_includes delivered_body, 'The new due date is not included in this email' + end +end From 50b1aab0f346796d5d2fdb8abd3729cb6f5e2a98 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 11:44:05 +1000 Subject: [PATCH 046/247] fix(notifications): handle queue and fan-out failures --- app/api/task_definitions_api.rb | 16 +- .../new_task_available_notification_job.rb | 22 +- test/api/units/task_definitions_api_test.rb | 251 ++++++++++++------ test/models/notification_new_task_test.rb | 24 +- 4 files changed, 214 insertions(+), 99 deletions(-) diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 70e84df196..5545b36500 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -109,9 +109,19 @@ class TaskDefinitionsApi < Grape::API task_def.save! - NewTaskAvailableNotificationJob.perform_async(task_def.id) - - present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) + # Notifications are best-effort and must not break task creation. + begin + NewTaskAvailableNotificationJob.perform_async(task_def.id) + rescue StandardError => e + Rails.logger.error( + "Failed to enqueue new-task notification for TaskDefinition #{task_def.id}: " \ + "#{e.class} - #{e.message}" + ) + end + + present task_def, + with: Entities::TaskDefinitionEntity, + my_role: unit.role_for(current_user) end desc 'Edits the given task definition' diff --git a/app/sidekiq/new_task_available_notification_job.rb b/app/sidekiq/new_task_available_notification_job.rb index 3dc5f03159..5204db0278 100644 --- a/app/sidekiq/new_task_available_notification_job.rb +++ b/app/sidekiq/new_task_available_notification_job.rb @@ -10,7 +10,7 @@ class NewTaskAvailableNotificationJob sidekiq_options lock: :until_executed, lock_args_method: ->(args) { [args.first] }, on_conflict: :reject, - retry: false + retry: 3 def perform(task_definition_id) task_definition = TaskDefinition.find_by(id: task_definition_id) @@ -19,12 +19,25 @@ def perform(task_definition_id) unit = task_definition.unit return unless unit.active + failed_project_ids = [] + unit.projects .where(enrolled: true) .includes(:user) .find_each(batch_size: BATCH_SIZE) do |project| notify_project(project, task_definition) + rescue StandardError => e + failed_project_ids << project.id + + Rails.logger.error( + "Failed new-task notification for TaskDefinition #{task_definition.id}, " \ + "Project #{project.id}: #{e.class} - #{e.message}" + ) end + + return if failed_project_ids.empty? + + raise "New-task notifications failed for projects: #{failed_project_ids.join(', ')}" end private @@ -59,10 +72,5 @@ def notify_project(project, task_definition) message: "A new task is available: #{task_definition.abbreviation} in #{task_definition.unit.code}.", link: link ) - rescue StandardError => e - Rails.logger.error( - "Failed new-task notification for TaskDefinition #{task_definition.id}, " \ - "Project #{project.id}: #{e.class} - #{e.message}" - ) end -end \ No newline at end of file +end diff --git a/test/api/units/task_definitions_api_test.rb b/test/api/units/task_definitions_api_test.rb index 00967f32b0..1c9ec63020 100644 --- a/test/api/units/task_definitions_api_test.rb +++ b/test/api/units/task_definitions_api_test.rb @@ -1,4 +1,5 @@ require 'test_helper' +require 'minitest/mock' class TaskDefinitionsTest < ActiveSupport::TestCase include Rack::Test::Methods @@ -35,21 +36,21 @@ def test_task_definition_cud data_to_post = { task_def: { - tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation, - name: 'New Task Def', - description: 'First task def', - weighting: 4, - target_grade: 1, - group_set_id: unit.group_sets.first.id, - start_date: unit.start_date, - target_date: unit.start_date + 7.days, - due_date: unit.start_date + 21.days, - abbreviation: 'P1.1', - restrict_status_updates: false, - upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]', - plagiarism_warn_pct: 80, - is_graded: false, - max_quality_pts: 0 + tutorial_stream_abbr: unit.tutorial_streams.first.abbreviation, + name: 'New Task Def', + description: 'First task def', + weighting: 4, + target_grade: 1, + group_set_id: unit.group_sets.first.id, + start_date: unit.start_date, + target_date: unit.start_date + 7.days, + due_date: unit.start_date + 21.days, + abbreviation: 'P1.1', + restrict_status_updates: false, + upload_requirements: '[ { "key": "file0", "name": "Shape Class", "type": "document" } ]', + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 } } @@ -69,21 +70,21 @@ def test_task_definition_cud data_to_put = { task_def: { - tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation, - name: 'New Task Def 1', - description: 'First task def 1', - weighting: 2, - target_grade: 2, - group_set_id: nil, - start_date: unit.start_date + 2.days, - target_date: unit.start_date + 9.days, - due_date: unit.start_date + 23.days, - abbreviation: 'P1.2', - restrict_status_updates: true, - upload_requirements: [ { "key": "file0", "name": "Other Class", "type": "document" } ].to_json, - plagiarism_warn_pct: 80, - is_graded: false, - max_quality_pts: 0 + tutorial_stream_abbr: unit.tutorial_streams.last.abbreviation, + name: 'New Task Def 1', + description: 'First task def 1', + weighting: 2, + target_grade: 2, + group_set_id: nil, + start_date: unit.start_date + 2.days, + target_date: unit.start_date + 9.days, + due_date: unit.start_date + 23.days, + abbreviation: 'P1.2', + restrict_status_updates: true, + upload_requirements: [{ "key": "file0", "name": "Other Class", "type": "document" }].to_json, + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 } } @@ -101,6 +102,80 @@ def test_task_definition_cud assert_equal 2, td.weighting end + def new_task_definition_payload(unit) + { + task_def: { + name: 'Notification Queue Test', + description: 'Task used to test notification queue behaviour', + weighting: 1, + target_grade: 1, + start_date: unit.start_date, + target_date: unit.start_date + 7.days, + due_date: unit.start_date + 14.days, + abbreviation: "QUEUE#{SecureRandom.hex(3)}", + restrict_status_updates: false, + plagiarism_warn_pct: 80, + is_graded: false, + max_quality_pts: 0 + } + } + end + + def test_task_definition_creation_enqueues_new_task_notification + unit = FactoryBot.create(:unit, task_count: 0) + enqueued_task_definition_id = nil + + enqueue = lambda do |task_definition_id| + enqueued_task_definition_id = task_definition_id + end + + NewTaskAvailableNotificationJob.stub(:perform_async, enqueue) do + add_auth_header_for(user: unit.main_convenor_user) + + post_json( + "/api/units/#{unit.id}/task_definitions", + new_task_definition_payload(unit) + ) + end + + assert_equal 201, last_response.status, last_response_body + + created_task_definition = unit.task_definitions.order(:id).last + + assert_equal( + created_task_definition.id, + enqueued_task_definition_id + ) + end + + def test_task_definition_creation_succeeds_when_enqueue_fails + unit = FactoryBot.create(:unit, task_count: 0) + + enqueue_failure = lambda do |_task_definition_id| + raise StandardError, 'Redis unavailable' + end + + NewTaskAvailableNotificationJob.stub( + :perform_async, + enqueue_failure + ) do + add_auth_header_for(user: unit.main_convenor_user) + + assert_difference('TaskDefinition.count', 1) do + post_json( + "/api/units/#{unit.id}/task_definitions", + new_task_definition_payload(unit) + ) + end + end + + assert_equal 201, last_response.status, last_response_body + assert_equal( + 'Notification Queue Test', + unit.task_definitions.order(:id).last.name + ) + end + def test_post_invalid_file_tasksheet test_unit = FactoryBot.create(:unit, task_count: 1) test_task_definition_id = test_unit.task_definitions.first.id @@ -181,30 +256,30 @@ def test_post_task_resources ] # Save will trigger TII integration - create_tii_group_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*]). - with(tii_headers). - with(body: %r[.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*]). - to_return(status: 200, body: "", headers: {}) - - post_attachment_stub = stub_request(:post, %r[https://localhost/api/v1/groups/.*/attachments]). - with(tii_headers). - with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}"). - to_return( - status: 200, - body: TCAClient::AddGroupAttachmentResponse.new( - id: SecureRandom.uuid - ).to_json, - headers: {} - ) - - upload_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*/attachments/.*/original]). - with(tii_headers). - with(headers: {'Content-Type'=>'binary/octet-stream'}). - to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {}) - - delete_stub = stub_request(:delete, %r[https://localhost/api/v1/groups/.*/attachments/.*]). - with(tii_headers). - to_return(status: 200, body: "", headers: {}) + create_tii_group_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*]) + .with(tii_headers) + .with(body: %r[.*id.*.*name.*type.*ASSIGNMENT.*group_context.*id.*name.*due_date.*report_generation.*IMMEDIATELY_AND_DUE_DATE.*]) + .to_return(status: 200, body: "", headers: {}) + + post_attachment_stub = stub_request(:post, %r[https://localhost/api/v1/groups/.*/attachments]) + .with(tii_headers) + .with(body: "{\"title\":\"TestWordDoc.docx\",\"template\":false}") + .to_return( + status: 200, + body: TCAClient::AddGroupAttachmentResponse.new( + id: SecureRandom.uuid + ).to_json, + headers: {} + ) + + upload_stub = stub_request(:put, %r[https://localhost/api/v1/groups/.*/attachments/.*/original]) + .with(tii_headers) + .with(headers: { 'Content-Type' => 'binary/octet-stream' }) + .to_return(status: 200, body: '{ "message": "Successfully uploaded file for attachment ..." }', headers: {}) + + delete_stub = stub_request(:delete, %r[https://localhost/api/v1/groups/.*/attachments/.*]) + .with(tii_headers) + .to_return(status: 200, body: "", headers: {}) td.save! @@ -241,21 +316,21 @@ def test_post_scorm def test_submission_creates_folders unit = Unit.first td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'test_submission_creates_folders', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'test_submission_creates_folders', - restrict_status_updates: false, - upload_requirements: [ { "key" => "file0", "name" => "Shape Class", "type" => "document" } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'test_submission_creates_folders', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'test_submission_creates_folders', + restrict_status_updates: false, + upload_requirements: [{ "key" => "file0", "name" => "Shape Class", "type" => "document" }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -295,21 +370,21 @@ def test_submission_creates_folders def test_change_to_group_after_submissions unit = Unit.first td = TaskDefinition.new({ - unit_id: unit.id, - tutorial_stream: unit.tutorial_streams.first, - name: 'Task to switch from ind to group after submission', - description: 'test def', - weighting: 4, - target_grade: 0, - start_date: unit.start_date + 1.week, - target_date: unit.start_date + 2.weeks, - abbreviation: 'TaskSwitchIndGrp', - restrict_status_updates: false, - upload_requirements: [ { "key" => 'file0', "name" => 'Shape Class', "type" => 'document' } ], - plagiarism_warn_pct: 0.8, - is_graded: false, - max_quality_pts: 0 - }) + unit_id: unit.id, + tutorial_stream: unit.tutorial_streams.first, + name: 'Task to switch from ind to group after submission', + description: 'test def', + weighting: 4, + target_grade: 0, + start_date: unit.start_date + 1.week, + target_date: unit.start_date + 2.weeks, + abbreviation: 'TaskSwitchIndGrp', + restrict_status_updates: false, + upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'document' }], + plagiarism_warn_pct: 0.8, + is_graded: false, + max_quality_pts: 0 + }) td.save! data_to_post = { @@ -335,7 +410,7 @@ def test_change_to_group_after_submissions # Change it to a group task - group_set = GroupSet.create!({name: 'test group set', unit: unit}) + group_set = GroupSet.create!({ name: 'test group set', unit: unit }) group_set.save! td.group_set = group_set @@ -836,8 +911,8 @@ def test_task_related_to_task_def_when_multiple_projects_tasks_and_tutorials end def test_change_draft_learning_summary_upload_requirements - unit = FactoryBot.create :unit, student_count:1, task_count:0 - upload_reqs = [{'key' => 'file0','name' => 'Draft learning summary','type' => 'document'}] + unit = FactoryBot.create :unit, student_count: 1, task_count: 0 + upload_reqs = [{ 'key' => 'file0', 'name' => 'Draft learning summary', 'type' => 'document' }] task_def = FactoryBot.create(:task_definition, unit: unit, upload_requirements: upload_reqs) # Set draft learning summary task defintion @@ -857,7 +932,7 @@ def test_change_draft_learning_summary_upload_requirements # Test change upload requirements to a non-document upload data_to_put = { task_def: { - upload_requirements: [{"key": "file0","name": "Code file","type": "code"}].to_json + upload_requirements: [{ "key": "file0", "name": "Code file", "type": "code" }].to_json } } diff --git a/test/models/notification_new_task_test.rb b/test/models/notification_new_task_test.rb index e4fe884288..a3d67d5108 100644 --- a/test/models/notification_new_task_test.rb +++ b/test/models/notification_new_task_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'minitest/mock' # EN-V02: newly available tasks notify eligible students. class NotificationNewTaskTest < ActiveSupport::TestCase @@ -181,6 +182,27 @@ def test_future_effective_student_start_date_is_not_notified assert_empty ActionMailer::Base.deliveries end + def test_notification_failure_makes_job_fail_for_retry + notification_failure = lambda do |**_args| + raise StandardError, 'temporary notification failure' + end + + NotificationService.stub(:notify, notification_failure) do + error = assert_raises(RuntimeError) do + run_job + end + + assert_includes error.message, @project.id.to_s + end + end + + def test_job_has_limited_retries + assert_equal( + 3, + NewTaskAvailableNotificationJob.get_sidekiq_options['retry'] + ) + end + def test_running_fan_out_twice_does_not_duplicate_notification run_job @@ -194,4 +216,4 @@ def test_running_fan_out_twice_does_not_duplicate_notification assert_equal 1, event_notifications.count assert_equal 1, ActionMailer::Base.deliveries.count end -end \ No newline at end of file +end From ead9f14a5b0a452bd11a21587d43aa025446cb90 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 11:51:52 +1000 Subject: [PATCH 047/247] docs(notifications): correct existing email audit --- docs/notifications/existing-emails.md | 109 +++++++++++++++++--------- 1 file changed, 72 insertions(+), 37 deletions(-) diff --git a/docs/notifications/existing-emails.md b/docs/notifications/existing-emails.md index 6ed3d575d6..f6df0e7c60 100644 --- a/docs/notifications/existing-emails.md +++ b/docs/notifications/existing-emails.md @@ -1,42 +1,63 @@ # Existing Email Audit -This document records the email behaviour that already exists in OnTrack before -the v2 notification work. Its purpose is to prevent new notification events from -duplicating existing email behaviour or accidentally sending multiple messages -for the same action. +This document records email behaviour that already exists in OnTrack and the +shared email-delivery paths used by the v2 notification work. Its purpose is to +prevent new notification events from duplicating existing email behaviour or +accidentally sending multiple messages for the same action. The audit covers both the API mailers and the existing communication subsystem in the web application. +## Audit snapshot + +This audit was checked against `feature/notifications` at commit +`09a61714425f12e1412da5b7a34f31f7ea5612dd` on 15 August 2026. + +The primary reference for each send site is its class and method name. The +linked line ranges are pinned to the audited commit so later code changes do not +make the references point to unrelated code. + +The audit can be reproduced with: + +```bash +git grep -nE '\.deliver(_now|_later)?([^[:alnum:]_]|$)' \ + 09a61714425f12e1412da5b7a34f31f7ea5612dd -- app lib \ + | grep -Ev 'PushNotificationService\.deliver|def (self\.)?deliver(_now|_later)?([^[:alnum:]_]|$)' +``` + +The command returned 21 direct email delivery call sites after the push +delivery call was excluded. Each result was manually checked to confirm that +it sends an email. + ## API email send sites A search of `doubtfire-api` for `.deliver`, `.deliver_now`, and `.deliver_later` identified 21 real email send sites. Push notification service calls and method definitions are not counted as email send sites. -| Existing email | Trigger / send site | Recipient | Preference / guard | +| Existing email | Trigger / stable send-site reference | Recipient | Preference / guard | | --- | --- | --- | --- | -| Turnitin error log | `app/helpers/turn_it_in.rb:88` – Turnitin credential/error handling | Configured administrator/error-log address | None observed | -| Task PDF failed | `app/models/portfolio_evidence.rb:79` – task PDF generation failure | Project student | Task notification preference | -| Weekly student summary | `app/models/project.rb:682` – project weekly summary | Project student | Existing summary eligibility/preference logic | -| Task feedback ready | `app/models/unit.rb:2975` – feedback/PDF processing completes | Project student | Feedback notification preference | -| Weekly staff summary | `app/models/unit_role.rb:207` – staff weekly summary | Staff member represented by the unit role | Existing summary eligibility/preference logic | -| Single notification email | `app/services/notification_service.rb:69` – `NotificationService.notify` accepts an event for delivery | Notification user | Gated by the preference mapped from the notification category | -| Task PDF failed | `app/sidekiq/accept_submission_job.rb:36` – submitted task PDF conversion fails | Project student | `receive_task_notifications` | -| Submission processing error | `app/sidekiq/accept_submission_job.rb:55` – submission processing raises an error and produces an error mail | Administrator/error recipient | None observed | -| Archive error | `app/sidekiq/archive_old_units_job.rb:22` – old-unit archive operation produces an error mail | Administrator/error recipient | None observed | -| D2L grade transfer result | `app/sidekiq/d2l_post_grades_job.rb:27` – D2L grade transfer completes | User who initiated the transfer | None observed | -| D2L grade transfer failure | `app/sidekiq/d2l_post_grades_job.rb:35` – D2L grade transfer fails | User who initiated the transfer | None observed | -| Communication email to student | `app/sidekiq/execute_communication_set_job.rb:162` – an active communication rule executes for matched students | Students matched by the communication rule | Rule conditions determine recipients | -| Communication email to staff | `app/sidekiq/execute_communication_set_job.rb:203` – a staff-email communication action executes | Tutors and/or convenors selected by the rule | Recipient groups are configured in the rule | -| Communication action log | `app/sidekiq/execute_communication_set_job.rb:326` – communication execution produces its action log | Convenors | Operational communication email | -| Tutor note | `app/sidekiq/notify_tutor_notes_job.rb:8` – tutor-note notification job runs | Specific recipient supplied to the job | No preference gate observed in this send path | -| PDF-generation error mail | `lib/tasks/generate_pdfs.rake:145` – PDF generation produces an error mail | Administrator/error recipient | None observed | -| Portfolio ready | `lib/tasks/generate_pdfs.rake:157` – portfolio generation succeeds | Project student | `receive_portfolio_notifications` | -| Portfolio failed | `lib/tasks/generate_pdfs.rake:159` – portfolio generation fails | Project student | `receive_portfolio_notifications` | -| Task PDF failed – maintenance | `lib/tasks/maintenance.rake:50` – maintenance PDF processing fails | Project student | Existing task-notification guard in the maintenance flow | -| Maintenance error mail | `lib/tasks/maintenance.rake:72` – maintenance operation produces an error mail | Administrator/error recipient | None observed | -| Overseer assessment failed | `lib/tasks/overseer_notifications.rake:14` – failed Overseer assessments are grouped for notification | Affected project student | Existing Overseer notification flow | +| Turnitin error log | [`TurnItIn.handle_tii_error`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/helpers/turn_it_in.rb#L72-L80) – a Turnitin request returns HTTP 403 | Configured administrator/error-log address | Operational path; no user preference; attempted only for a 403 error | +| Task PDF failed – queued converter | [`PortfolioEvidence.process_new_to_pdf`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/portfolio_evidence.rb#L31-L75) – queued task PDF conversion reports a failure | Project student | `receive_task_notifications` | +| Weekly student summary | [`Project#send_weekly_status_email`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/project.rb#L619-L636) – weekly project summary is generated | Project student | `receive_feedback_notifications`; a final summary is skipped when a portfolio already exists | +| Task feedback ready | [`Unit#update_task_status_from_csv`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/unit.rb#L2632-L2755) – batch CSV/ZIP marking import finishes feedback/PDF processing | Project student | `receive_feedback_notifications` | +| Weekly staff summary | [`UnitRole#send_weekly_status_email`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/models/unit_role.rb#L187-L193) – weekly staff summary is generated | Staff member represented by the unit role | `receive_feedback_notifications` | +| Single notification email | [`NotificationService.notify` and `deliver_email`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/services/notification_service.rb#L24-L68) – an event is created and delivered through the notification service | Notification user | `task`, `feedback`, and `portfolio` map to existing preferences; `extension` and `general` currently have no mapping and are allowed by default | +| Task PDF failed – submission job | [`AcceptSubmissionJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/accept_submission_job.rb#L10-L55) – submitted task PDF conversion fails | Project student | `receive_task_notifications` | +| Submission processing error | [`AcceptSubmissionJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/accept_submission_job.rb#L10-L55) – submission processing raises an exception | Configured administrator/error recipient | Operational path; no user preference; only sent when an error mail is available | +| Archive error | [`ArchiveOldUnitsJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/archive_old_units_job.rb#L6-L24) – old-unit archiving raises an exception | Configured administrator/error recipient | Operational path; no user preference | +| D2L grade transfer result | [`D2lPostGradesJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/d2l_post_grades_job.rb#L9-L37) – D2L grade transfer completes | User who initiated the transfer | Direct workflow result; no notification preference check | +| D2L grade transfer failure | [`D2lPostGradesJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/d2l_post_grades_job.rb#L9-L37) – D2L grade transfer fails | User who initiated the transfer | Direct workflow result; no notification preference check | +| Communication email to student | [`ExecuteCommunicationSetJob#execute_email_student_action`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/execute_communication_set_job.rb#L116-L159) – an active communication rule matches a student and executes its student-email action | Student matched by the communication rule | No v2 preference check; requires rule match, configured action, recipient email and sender email | +| Communication email to staff | [`ExecuteCommunicationSetJob#execute_email_staff_action`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/execute_communication_set_job.rb#L163-L198) – a staff-email action executes | Tutors and/or convenors selected by the rule | No v2 preference check; requires configured recipient groups, available recipient addresses and sender email | +| Communication action log | [`ExecuteCommunicationSetJob#send_action_log_to_convenors`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/execute_communication_set_job.rb#L264-L311) – a communication execution produces its action log | Convenors | Requires `send_log_to_convenors?`, convenor addresses and sender email; no v2 preference check | +| Tutor note | [`NotifyTutorNotesJob#perform`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/app/sidekiq/notify_tutor_notes_job.rb#L4-L10) – tutor-note notification job runs | Specific recipient supplied to the job | No preference check in this job path | +| PDF-generation error mail | [`submission:generate_pdfs`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/generate_pdfs.rake#L86-L149) – portfolio/PDF generation raises an exception | Configured administrator/error recipient | Operational path; no user preference | +| Portfolio ready | [`submission:generate_pdfs`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/generate_pdfs.rake#L86-L149) – portfolio generation succeeds | Project student | `receive_portfolio_notifications` | +| Portfolio failed | [`submission:generate_pdfs`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/generate_pdfs.rake#L86-L149) – portfolio generation fails | Project student | `receive_portfolio_notifications` | +| Task PDF failed – maintenance | [`notify_failed_submission`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/maintenance.rake#L42-L68) – maintenance PDF processing identifies a failed submission | Project student | `receive_task_notifications` | +| Maintenance error mail | [`notify_failed_submission`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/maintenance.rake#L42-L68) – maintenance processing raises an error while handling the failure | Configured administrator/error recipient | Operational path; no user preference | +| Overseer assessment failed | [`notify_failed_overseer_assessments!`](https://github.com/ontrack-features-t2-2026/doubtfire-api/blob/09a61714425f12e1412da5b7a34f31f7ea5612dd/lib/tasks/overseer_notifications.rake#L2-L18) – unnotified Overseer assessment failures are grouped for delivery | Affected project student | Requires queued failure records and a nonblank student email; no explicit user preference check in this method | ## Mailers already present @@ -116,26 +137,40 @@ through it can generate a notification email after the relevant notification preference check. An event must not also retain an independent legacy email unless two messages are explicitly intended. +The current target branch also includes the `task_status_changed` event. This +event calls `NotificationService.notify`, so it reuses the single notification +email delivery path listed above. It does not introduce a separate direct +`.deliver`, `.deliver_now`, or `.deliver_later` call and therefore does not +increase the direct send-site count. + ## Recipient and preference observations Existing emails do not use one common preference mechanism. -Some student-facing flows explicitly check preferences, including task, -feedback and portfolio notification settings. `NotificationService` also maps -notification categories to user preferences before delivery. +Task-PDF failure paths use `receive_task_notifications`. The batch feedback-ready +email, weekly student summary and weekly staff summary use +`receive_feedback_notifications`. Portfolio-ready and portfolio-failed emails +use `receive_portfolio_notifications`. + +`NotificationService` only maps `task`, `feedback`, and `portfolio` to existing +preference fields. The `extension` and `general` types have no preference +mapping, so `NotificationService.deliver_to?` currently allows them by default. -Other emails are operational messages or direct workflow results and have no -user preference gate in their send path. These include administrator error -messages and D2L transfer results. +Communication-rule emails do not use the v2 preference mapping. They are +controlled by rule matching, action configuration, available recipient +addresses and an available sender address. The action-log email also requires +the rule's `send_log_to_convenors?` setting and at least one convenor email. -The communication-rule subsystem determines recipients from rule conditions -and configured recipient groups rather than the v2 notification preference -mapping. +Administrator error emails and D2L result emails have no user notification +preference check. Error emails depend on the operational error-email +configuration, while D2L result emails are sent directly to the user who +initiated the transfer. This distinction must be preserved when an existing email is migrated or connected to a v2 event. Adding a second preference check without understanding -the legacy behaviour could either suppress a required operational message or -allow duplicate user-facing mail. +the legacy path could suppress a required operational email. Keeping both an +independent legacy send and a v2 send could instead cause duplicate user-facing +email. ## Conclusion From 054eac919e9b40baf423e0155e8bfa1ebe8e016e Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 11:56:01 +1000 Subject: [PATCH 048/247] docs(notifications): correct local push testing guidance --- docs/notifications/testing-push-locally.md | 33 ++++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/notifications/testing-push-locally.md b/docs/notifications/testing-push-locally.md index 0211ea1d23..426e11bdef 100644 --- a/docs/notifications/testing-push-locally.md +++ b/docs/notifications/testing-push-locally.md @@ -94,8 +94,10 @@ Only once that works should you go anywhere near a tunnel. ## 2. The trap: a phone on your wifi is not a secure context -**Verified** — the underlying rule is the same secure-context rule as above. Not -tested with a physical phone. +**Verified on a physical phone.** MN-Q03 reproduced this on an iPhone 16 +running iOS 26.6 on 13 Aug 2026. The phone reached the api over the LAN +address, but the push opt-in remained disabled because the page was not a +secure context. `angular.json` sets the dev server to `"host": "0.0.0.0"`, so `ng serve` listens on every interface, not just loopback. Your laptop's LAN address works. You can @@ -227,6 +229,16 @@ In `angular.json`, under `projects.doubtfire.architect.serve.options`: }, ``` +Changing `angular.json` does not update the already-running dev server. Restart +the web service before opening the tunnel hostname. Run this from +`doubtfire-deploy/development`: + + docker compose -f docker-compose.yml -f docker-compose.local-paths.yml \ + restart doubtfire-web + +If the frontend is running directly with `npm start`, stop it and start it again +instead. + The schema also accepts `"allowedHosts": true` to allow everything. Its own description calls that "not recommended and a security risk", which is fair, since your dev server is on the public internet for as long as the tunnel is up. Use it @@ -319,16 +331,19 @@ context first, before anything else: window.isSecureContext && 'serviceWorker' in navigator ``` -Then sign in, wait out the six second service worker registration delay, and use -MN-C01's opt-in button. Confirm the subscription actually landed: +Before subscribing on the phone, record the existing rows: docker exec doubtfire-api bundle exec rails runner \ - 'puts PushSubscription.all.map { |s| "#{s.user.username} #{s.endpoint[0, 60]}" }' + 'puts PushSubscription.order(:id).map { |s| "#{s.id} #{s.user.username} #{s.endpoint[0, 80]}" }' + +Then sign in, wait out the six second service worker registration delay, and use +MN-C01's opt-in button. Run the same command again. The new or changed row is +the phone's subscription. -The endpoint for a phone will be an FCM URL rather than whatever your desktop -browser uses. That is how you know you are looking at the phone's row and not the -one you made on localhost earlier. Then trigger an event as another user and -watch. +Do not identify the device only from the endpoint host. Chrome and Edge commonly +use FCM on both desktop and Android, Firefox uses Mozilla Push, and Safari/iOS +uses Apple Web Push. Comparing the rows before and after subscribing is the +reliable check. Then trigger an event as another user and watch. ### Things that will probably go wrong From 8c920447b81ae3c7050ef741755708462cfdfdee Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 12:08:42 +1000 Subject: [PATCH 049/247] fix(notifications): suppress bulk import notifications --- app/models/unit.rb | 4 ++- .../events/group_membership_changed.md | 11 ++++++-- test/models/notification_group_test.rb | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/models/unit.rb b/app/models/unit.rb index 19e0098298..ab1d4440bd 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1633,7 +1633,9 @@ def import_student_groups_from_csv(group_set, file) project.enrol_in(grp.tutorial) end - grp.add_member(project) + # Bulk imports can add many students in one request. Do not send a + # separate notification for every CSV row. + grp.add_member(project, notify: false) success << { row: row, message: "Added #{username} to #{grp.name}." } rescue Exception => e diff --git a/docs/notifications/events/group_membership_changed.md b/docs/notifications/events/group_membership_changed.md index 791467f9ac..b16d3ad653 100644 --- a/docs/notifications/events/group_membership_changed.md +++ b/docs/notifications/events/group_membership_changed.md @@ -4,7 +4,7 @@ |---|---| | Event name | `group_membership_changed` | | Category | `general` | -| What triggers it | A student's real group membership changes through `Group#add_member` or `Group#remove_member`. Internal remove/add operations performed by `Group#switch_to_tutorial` must not trigger this event. | +| What triggers it | A student's direct group membership changes through `Group#add_member` or `Group#remove_member`. Internal tutorial-switch operations and bulk CSV imports are intentionally suppressed. | | Who receives it | Only the student whose membership changed (`project.student`). Other members of the group are not notified. This recipient scope was confirmed with the Email Notifications lead. | | Preference that gates it | none, always sent | | Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | @@ -21,6 +21,12 @@ Only the student who was added to or removed from the group receives the notific `Group#switch_to_tutorial` temporarily removes and re-adds members while moving the group to another tutorial. These internal membership operations do not represent a real group membership change and must not send a leave-then-join notification pair. +## Bulk CSV import guard + +`Unit#import_student_groups_from_csv` may add many students in one request. It calls `Group#add_member(..., notify: false)` so the import does not create and deliver one notification for every CSV row. + +If bulk-import notifications are required later, they should be queued or batched after a successful import rather than delivered separately inside the import request. + ## Implementation The event uses: @@ -50,4 +56,5 @@ The tests cover: - removing a member sends one notification to the affected student - other group members are not notified - `switch_to_tutorial` does not send a leave-then-join notification pair -- a notification failure does not stop the membership change \ No newline at end of file +- a notification failure does not stop the membership change +- bulk CSV imports add students without raising per-student notifications \ No newline at end of file diff --git a/test/models/notification_group_test.rb b/test/models/notification_group_test.rb index 5849b3bfac..4cdd168a80 100644 --- a/test/models/notification_group_test.rb +++ b/test/models/notification_group_test.rb @@ -1,5 +1,6 @@ require 'test_helper' require 'minitest/mock' +require 'tempfile' # EN-V05: notify only the affected student when group membership changes. class NotificationGroupTest < ActiveSupport::TestCase @@ -118,4 +119,28 @@ def test_notification_failure_does_not_stop_membership_change assert_includes @group.reload.projects, @project end + + def test_bulk_csv_import_adds_member_without_notification + Tempfile.create(['student-groups', '.csv']) do |file| + file.write("group_name,username\n#{@group.name},#{@student.username}\n") + file.flush + + notification_calls = 0 + + NotificationService.stub :notify, ->(**_kwargs) { notification_calls += 1 } do + result = @project.unit.import_student_groups_from_csv( + @group.group_set, + file.path + ) + + assert_empty result[:errors], result.inspect + assert_empty result[:ignored], result.inspect + assert_equal 1, result[:success].count, result.inspect + end + + assert_equal 0, notification_calls + end + + assert_includes @group.reload.projects, @project + end end From 2269e062deb50598d08ff4f767f3b36760c4fa5b Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 12:40:29 +1000 Subject: [PATCH 050/247] fix(notifications): handle failed extension grants --- app/models/comments/extension_comment.rb | 82 ++++++++++--------- .../events/extension_assessed.md | 43 ++++++---- test/models/notification_extension_test.rb | 26 ++++++ 3 files changed, 98 insertions(+), 53 deletions(-) diff --git a/app/models/comments/extension_comment.rb b/app/models/comments/extension_comment.rb index 82b982ac26..9084fbc135 100644 --- a/app/models/comments/extension_comment.rb +++ b/app/models/comments/extension_comment.rb @@ -27,52 +27,58 @@ def mark_as_read(user, unit = self.unit) super if assessed? || user == project.student || user != recipient end -def assess_extension(user, granted, automatic = false) - if self.assessed? - self.errors[:extension] << 'has already been assessed' - return false - end + def assess_extension(user, granted, automatic = false) + if self.assessed? + errors.add(:extension, 'could not be applied') + return false + end - self.assessor = user - self.date_extension_assessed = Time.zone.now - self.extension_granted = granted && self.task.can_apply_for_extension? + can_apply = self.task.can_apply_for_extension? + should_grant = granted && can_apply + + if should_grant && !self.task.grant_extension(user, extension_weeks) + errors.add(:extension, 'could not be applied') + return false + end - should_notify = true + self.assessor = user + self.date_extension_assessed = Time.zone.now + self.extension_granted = should_grant - if self.extension_granted - self.task.grant_extension(user, extension_weeks) + should_notify = true - if automatic - self.extension_response = "Time extended to #{self.task.due_date.strftime('%a %b %e')}" + if self.extension_granted + if automatic + self.extension_response = "Time extended to #{self.task.due_date.strftime('%a %b %e')}" + else + self.extension_response = "Extension granted to #{self.task.due_date.strftime('%a %b %e')}" + end + elsif !can_apply && granted + self.extension_response = "Extension cannot be granted as deadline has been reached" + errors.add(:extension, 'cannot be granted as deadline has been reached') + should_notify = false else - self.extension_response = "Extension granted to #{self.task.due_date.strftime('%a %b %e')}" + self.extension_response = "Extension rejected" end - elsif !self.task.can_apply_for_extension? && granted - self.extension_response = "Extension cannot be granted as deadline has been reached" - errors[:extension] << 'cannot be granted as deadline has been reached' - should_notify = false - else - self.extension_response = "Extension rejected" - end - # Now make sure to read it by the main tutor - even if assessed by someone else - super_mark_as_read(project.tutor_for(task.task_definition)) - save! + # Now make sure to read it by the main tutor - even if assessed by someone else + super_mark_as_read(project.tutor_for(task.task_definition)) + save! - if should_notify - begin - NotificationService.notify( - user: project.student, - type: 'extension', - event: 'extension_assessed', - message: extension_response, - link: "/projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" - ) - rescue StandardError => e - Rails.logger.error "Failed to notify student about extension assessment: #{e.message}" + if should_notify + begin + NotificationService.notify( + user: project.student, + type: 'extension', + event: 'extension_assessed', + message: extension_response, + link: "/projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" + ) + rescue StandardError => e + Rails.logger.error "Failed to notify student about extension assessment: #{e.message}" + end end - end - true -end + true + end end diff --git a/docs/notifications/events/extension_assessed.md b/docs/notifications/events/extension_assessed.md index 813613ff1f..bb757e955a 100644 --- a/docs/notifications/events/extension_assessed.md +++ b/docs/notifications/events/extension_assessed.md @@ -1,22 +1,35 @@ # Event: extension_assessed -## What it does +| Field | Value | +| --- | --- | +| Event name | `extension_assessed` | +| Category | `extension` | +| What triggers it | A tutor or the automatic extension flow assesses an extension request through `ExtensionComment#assess_extension`. | +| Who receives it | `project.student`. The notification is only raised after the assessment is successfully saved. | +| Preference that gates it | `none, always sent` | +| Email subject | `#{product name}: New notification`, built by `NotificationsMailer#single_notification`. | +| Email body summary | Tells the student whether the extension was granted or rejected. A granted notification includes the updated due date. The event uses `extension_assessed.html.erb` and `extension_assessed.text.erb`. | +| Where it is raised | `app/models/comments/extension_comment.rb`, in `ExtensionComment#assess_extension`, after `save!`. | -A tutor assesses a student's extension request. +## Failure behaviour -- If the extension is granted, the student is notified and the message includes the new due date. -- If the extension is denied, the student is notified that the request was rejected. -- Failed assessment paths do not send a notification. +No notification or email is sent when: -## Where it is raised +- the extension request was already assessed; +- the deadline prevents an extension from being granted; or +- `Task#grant_extension` does not successfully apply the extension. -`app/models/comments/extension_comment.rb`, inside `assess_extension`, after the extension assessment has successfully saved. +A failed `grant_extension` call also leaves the extension request unassessed so that the system does not record or communicate a false successful result. -```ruby -NotificationService.notify( - user: project.student, - type: 'extension', - event: 'extension_assessed', - message: extension_response, - link: "/projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" -) \ No newline at end of file +## Tests + +`test/models/notification_extension_test.rb` + +The tests cover: + +- granted extensions and the updated due date; +- rejected extensions; +- already-assessed requests; +- deadline failures; +- a failed `grant_extension` operation; +- HTML and text event-specific templates. \ No newline at end of file diff --git a/test/models/notification_extension_test.rb b/test/models/notification_extension_test.rb index 25ef5034c1..2f0b709dd8 100644 --- a/test/models/notification_extension_test.rb +++ b/test/models/notification_extension_test.rb @@ -128,6 +128,32 @@ def test_deadline_error_does_not_send_notification assert_empty ActionMailer::Base.deliveries end + def test_failed_grant_does_not_assess_or_notify_student + extension = create_extension_request + task = extension.task + original_extensions = task.extensions + + task.stub :can_apply_for_extension?, true do + task.stub :grant_extension, false do + assert_no_difference 'Notification.count' do + result = extension.assess_extension(@tutor, true) + + assert_equal false, result + end + end + end + + assert_empty ActionMailer::Base.deliveries + assert_includes extension.errors[:extension], 'could not be applied' + refute extension.assessed? + refute extension.extension_granted + assert_equal original_extensions, task.reload.extensions + + extension.reload + refute extension.assessed? + refute extension.extension_granted + end + def test_extension_notification_uses_event_specific_templates extension = create_extension_request From 0f9a7305595e2067456ad3f2ad40e25cf4dd1c77 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 12:48:36 +1000 Subject: [PATCH 051/247] fix(notifications): queue safe due-date change fan-out --- app/api/task_definitions_api.rb | 20 +++ app/models/task_definition.rb | 32 ---- .../task_due_date_changed_notification_job.rb | 59 +++++++ .../events/task_due_date_changed.md | 136 ++++++++------- test/api/units/task_definitions_api_test.rb | 89 ++++++++++ test/models/notification_due_date_test.rb | 116 ------------- ..._due_date_changed_notification_job_test.rb | 160 ++++++++++++++++++ 7 files changed, 400 insertions(+), 212 deletions(-) create mode 100644 app/sidekiq/task_due_date_changed_notification_job.rb delete mode 100644 test/models/notification_due_date_test.rb create mode 100644 test/sidekiq/task_due_date_changed_notification_job_test.rb diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 5e0be83ee7..c0b89ad6c2 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -213,6 +213,7 @@ class TaskDefinitionsApi < Grape::API # Bulk update task definition with permitted parameters task_def.update!(task_params) + due_date_change = task_def.saved_change_to_due_date # Set the tutorial stream tutorial_stream_abbr = params[:task_def][:tutorial_stream_abbr] @@ -266,6 +267,25 @@ class TaskDefinitionsApi < Grape::API end end + if due_date_change + previous_due_date, new_due_date = due_date_change.map do |value| + value&.to_date&.iso8601 + end + + begin + TaskDueDateChangedNotificationJob.perform_async( + task_def.id, + previous_due_date, + new_due_date + ) + rescue StandardError => e + Rails.logger.error( + "Failed to enqueue due-date notification for TaskDefinition " \ + "#{task_def.id}: #{e.class} - #{e.message}" + ) + end + end + present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) end diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 029cdaa969..7ef377811f 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -63,7 +63,6 @@ def self.permissions after_update :update_tii_group, if: :saved_change_to_due_date? after_update :update_overdue_tasks_aip, if: :saved_change_to_assess_in_portfolio_only? after_update :reset_overdue_tasks, if: :saved_change_to_due_date? - after_update :notify_students_of_due_date_change, if: :saved_change_to_due_date? # Model associations belongs_to :unit, optional: false # Foreign key @@ -270,37 +269,6 @@ def reset_overdue_tasks end end - # Emails every student who has this task that its due date changed. A single - # due date change can affect a whole cohort, so this walks the tasks the same - # way reset_overdue_tasks does and sends one notification to each student. Only - # enrolled students are reached, and staff are never notified. The new date is - # left out of the message on purpose, because the email is meant to prompt the - # student to sign in rather than to carry the date itself. NotificationService - # handles the preference check and the delivery. - def notify_students_of_due_date_change - tasks.joins(:project).where(projects: { enrolled: true }).find_each do |task| - notify_student_of_due_date_change(task) - end - end - - # Each student is handled on its own so that one failure cannot stop the rest - # of the notifications and cannot roll back the due date change that triggered - # them. - def notify_student_of_due_date_change(task) - student = task.project.student - return if student.nil? - - NotificationService.notify( - user: student, - type: 'task', - event: 'task_due_date_changed', - message: "The due date for #{abbreviation} in #{unit.code} has changed.", - link: "/projects/#{task.project.id}/dashboard/#{abbreviation}" - ) - rescue StandardError => e - Rails.logger.error "Failed to raise task_due_date_changed notification for task #{task.id}: #{e.message}" - end - def move_files_on_abbreviation_change old_abbr = saved_change_to_abbreviation[0] # 0 is original abbreviation if File.exist? task_sheet_with_abbreviation(old_abbr, false) diff --git a/app/sidekiq/task_due_date_changed_notification_job.rb b/app/sidekiq/task_due_date_changed_notification_job.rb new file mode 100644 index 0000000000..63a80fa7b2 --- /dev/null +++ b/app/sidekiq/task_due_date_changed_notification_job.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class TaskDueDateChangedNotificationJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'task_due_date_changed' + TYPE = 'task' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args.first(3) }, + on_conflict: :reject, + retry: false + + def perform(task_definition_id, _previous_due_date, new_due_date) + task_definition = TaskDefinition.find_by(id: task_definition_id) + return if task_definition.nil? + return unless task_definition.unit.active + return unless current_due_date(task_definition) == new_due_date + + eligible_projects(task_definition).find_each(batch_size: BATCH_SIZE) do |project| + notify_project(project, task_definition) + end + end + + private + + def eligible_projects(task_definition) + task_definition.unit + .active_projects + .where( + 'projects.target_grade >= ?', + task_definition.target_grade + ) + .includes(:user) + end + + def current_due_date(task_definition) + task_definition[:due_date]&.to_date&.iso8601 + end + + def notify_project(project, task_definition) + NotificationService.notify( + user: project.student, + type: TYPE, + event: EVENT, + message: "The due date for #{task_definition.abbreviation} " \ + "in #{task_definition.unit.code} has changed.", + link: "/projects/#{project.id}/dashboard/" \ + "#{task_definition.abbreviation}" + ) + rescue StandardError => e + Rails.logger.error( + "Failed due-date notification for TaskDefinition " \ + "#{task_definition.id}, Project #{project.id}: " \ + "#{e.class} - #{e.message}" + ) + end +end diff --git a/docs/notifications/events/task_due_date_changed.md b/docs/notifications/events/task_due_date_changed.md index 56207ec4f2..b2c19687ec 100644 --- a/docs/notifications/events/task_due_date_changed.md +++ b/docs/notifications/events/task_due_date_changed.md @@ -1,93 +1,101 @@ # Event: task_due_date_changed -A staff member changes a task's due date. Every student who has that task is -told, one email each. Ticket EN (email when a task due date changes). +## Purpose -Built the same way as the worked example in `task_comment_created.md`; read that -one first. +Notify eligible students when a convenor changes a task definition's due date +through the normal task-definition update API. -## What it does +## Trigger -A convenor changes a task definition's due date, and every enrolled student who -has that task gets an email telling them the date changed. This means they find -out from OnTrack instead of discovering the change themselves. +The trigger is in `app/api/task_definitions_api.rb`. -## Hook point (confirmed) +Immediately after `task_def.update!(task_params)`, the API captures +`saved_change_to_due_date`. After the rest of the update succeeds, it enqueues +`TaskDueDateChangedNotificationJob`. -`app/models/task_definition.rb`. The due date lives on the **task definition**, -and two existing callbacks already fire on a due-date change: +A `TaskDefinition` model callback is deliberately not used. Task definitions +can also be saved by unit date propagation, imports, rollovers, copies and +internal maintenance. A model callback could therefore create unexpected +cohort-wide email fan-out. - after_update :update_tii_group, if: :saved_change_to_due_date? # line 63 - after_update :reset_overdue_tasks, if: :saved_change_to_due_date? # line 65 +## Queue -This event adds a third callback in the same shape, right after them: +The API request does not perform the cohort email and push fan-out directly. +`TaskDueDateChangedNotificationJob` performs the fan-out through Sidekiq. - after_update :notify_students_of_due_date_change, if: :saved_change_to_due_date? +A functioning Sidekiq worker must consume the same +`DF_REDIS_SIDEKIQ_URL` used by the API. -`saved_change_to_due_date?` only fires after the new due date has been saved, so -the notification never runs on a change that did not commit. +## Recipient eligibility -## Reaching the affected students +A project is eligible only when: -`notify_students_of_due_date_change` walks `tasks` the same way -`reset_overdue_tasks` (task_definition.rb:257) does, filtered to enrolled -projects, and sends one notification per student: +- the unit is active; +- the project is enrolled; +- the project's target grade is at least the task definition's target grade; + and +- the student has task notifications enabled. - NotificationService.notify( - user: task.project.student, - type: 'task', - event: 'task_due_date_changed', - message: "The due date for #{abbreviation} in #{unit.code} has changed.", - link: "/projects/#{task.project.id}/dashboard/#{abbreviation}" - ) +Recipients are selected from projects rather than existing Task rows. OnTrack +creates Task rows on demand, so an eligible student may not yet have one. The +notification job does not create Task rows. -A due date change can affect a whole cohort at once, so the fan-out is per task -and each send is isolated in `notify_student_of_due_date_change`. This way one -student failing cannot stop the rest and cannot roll back the due date save. +## Stale jobs and duplicate queue entries -## Fields +The job receives: -| Field | Value | -|---|---| -| `type` | `task`, so each student's `receive_task_notifications` switch controls it | -| `event` | `task_due_date_changed` | -| `message` | Names the task and unit. Never the new (or old) due date value | -| `link` | `/projects//dashboard/` | +- the task definition ID; +- the previous stored due date; and +- the new stored due date. -## Templates +Before sending, it checks that the task definition still has the queued new raw +due date. This prevents an outdated job from sending after the due date has +changed again. -- `app/views/notifications_mailer/task_due_date_changed.text.erb` -- `app/views/notifications_mailer/task_due_date_changed.html.erb` +Sidekiq uniqueness rejects another pending or executing job with the same task +definition ID and old/new date values. -Picked up automatically by `NotificationsMailer#event_template_name`; the mailer -is not edited. +Automatic retries are disabled because retrying a partly completed cohort +fan-out could create duplicate notifications for students already processed. +A failure for one project is logged without stopping the remaining projects. -## Three things to know before you copy this +## Notification fields -1. **Only enrolled students are reached.** The walk filters - `projects.enrolled = true`, so withdrawn students and staff are not emailed. +- Type: `task` +- Event: `task_due_date_changed` +- Message: names the task and unit but does not expose the due date +- Link: `/projects/:project_id/dashboard/:task_abbreviation` +- Preference: `receive_task_notifications` -2. **One student, one email.** A student has a single task per definition, so the - fan-out sends exactly one notification to each student rather than one per - event listener. +## Bulk unit date changes -3. **A notification must never break the save.** Each send is wrapped in a - `rescue StandardError` that logs and swallows, so changing a due date succeeds - even if a notification fails. +Changing a unit start date updates many task definitions internally. This +implementation does not send one email per changed task for that path. -## How to check it by hand +A future unit-level notification or digest should cover bulk schedule changes +without sending many separate emails to each student. -1. As a convenor, change a task's due date. -2. Each enrolled student who has that task gets one email at - http://localhost:8025 (Mailpit). It names the task but not the new date. -3. A student in another unit, and a withdrawn student, get nothing. -4. Turn a student's task notifications off in their profile, change the date - again, and that student gets no email while the others still do. +## Templates + +- `app/views/notifications_mailer/task_due_date_changed.text.erb` +- `app/views/notifications_mailer/task_due_date_changed.html.erb` ## Tests -`test/models/notification_due_date_test.rb`. It covers the full fan-out, that a -student in another unit and a withdrawn student are not notified, the preference -switch, the new date staying out of the message, the link, and the -event-specific template. Run it on its own, because the test database is the -development database. See item 11 in `doubtfire-deploy/RUNNING-LOCALLY.md`. +- `test/sidekiq/task_due_date_changed_notification_job_test.rb` +- `test/api/units/task_definitions_api_test.rb` + +The tests cover: + +- eligible students without Task rows; +- target-grade filtering; +- withdrawn students; +- notification preferences; +- inactive units; +- stale jobs; +- privacy-safe message content and links; +- the event-specific email template; +- enqueue on a due-date API update; +- no enqueue for unrelated updates; +- no enqueue from direct model updates; and +- queue failure not breaking the core due-date update. \ No newline at end of file diff --git a/test/api/units/task_definitions_api_test.rb b/test/api/units/task_definitions_api_test.rb index 00967f32b0..7c83f747a8 100644 --- a/test/api/units/task_definitions_api_test.rb +++ b/test/api/units/task_definitions_api_test.rb @@ -1,4 +1,5 @@ require 'test_helper' +require 'minitest/mock' class TaskDefinitionsTest < ActiveSupport::TestCase include Rack::Test::Methods @@ -984,4 +985,92 @@ def test_download_student_submission_jobs end end end + + def test_due_date_update_enqueues_notification_job + unit = FactoryBot.create(:unit, task_count: 1) + task_def = unit.task_definitions.first + + previous_due_date = task_def[:due_date]&.to_date&.iso8601 + new_due_date = (task_def.due_date + 1.week).to_date + + data_to_put = { + task_def: { + due_date: new_due_date + } + } + + add_auth_header_for(user: unit.main_convenor_user) + + assert_difference( + -> { TaskDueDateChangedNotificationJob.jobs.size }, + 1 + ) do + put_json( + "/api/units/#{unit.id}/task_definitions/#{task_def.id}", + data_to_put + ) + end + + assert_equal 200, last_response.status, last_response_body + + assert_equal( + [ + task_def.id, + previous_due_date, + new_due_date.iso8601 + ], + TaskDueDateChangedNotificationJob.jobs.last['args'] + ) + end + + def test_unrelated_update_does_not_enqueue_due_date_notification_job + unit = FactoryBot.create(:unit, task_count: 1) + task_def = unit.task_definitions.first + + data_to_put = { + task_def: { + description: 'Updated without moving the due date.' + } + } + + add_auth_header_for(user: unit.main_convenor_user) + + assert_no_difference( + -> { TaskDueDateChangedNotificationJob.jobs.size } + ) do + put_json( + "/api/units/#{unit.id}/task_definitions/#{task_def.id}", + data_to_put + ) + end + + assert_equal 200, last_response.status, last_response_body + end + + def test_due_date_update_succeeds_when_enqueue_fails + unit = FactoryBot.create(:unit, task_count: 1) + task_def = unit.task_definitions.first + new_due_date = (task_def.due_date + 1.week).to_date + + data_to_put = { + task_def: { + due_date: new_due_date + } + } + + add_auth_header_for(user: unit.main_convenor_user) + + TaskDueDateChangedNotificationJob.stub( + :perform_async, + ->(*) { raise StandardError, 'Redis unavailable' } + ) do + put_json( + "/api/units/#{unit.id}/task_definitions/#{task_def.id}", + data_to_put + ) + end + + assert_equal 200, last_response.status, last_response_body + assert_equal new_due_date, task_def.reload[:due_date].to_date + end end diff --git a/test/models/notification_due_date_test.rb b/test/models/notification_due_date_test.rb deleted file mode 100644 index cf8d256c3f..0000000000 --- a/test/models/notification_due_date_test.rb +++ /dev/null @@ -1,116 +0,0 @@ -require 'test_helper' -require 'minitest/mock' - -# EN: changing a task definition's due date emails every enrolled student who has -# that task, one each, and nobody else. -class NotificationDueDateTest < ActiveSupport::TestCase - setup do - @unit = FactoryBot.create(:unit) - @task_def = @unit.task_definitions.first - - # Tasks are created on demand, so materialise one per active project to give - # the fan-out records to walk (the same tasks a real cohort would have). - @unit.active_projects.each { |p| p.task_for_task_definition(@task_def) } - - @affected = @task_def.tasks - .joins(:project) - .where(projects: { enrolled: true }) - .map { |t| t.project.student } - .uniq - - ActionMailer::Base.deliveries.clear - end - - def delivered_body - mail = ActionMailer::Base.deliveries.last - return '' if mail.nil? - - mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded - end - - def change_due_date - @task_def.update!(due_date: @task_def.due_date + 1.week) - end - - def test_every_affected_student_is_emailed_once - assert @affected.size >= 2, 'guard: need several students for a meaningful fan-out' - - assert_difference 'Notification.count', @affected.size do - change_due_date - end - - assert_equal @affected.size, ActionMailer::Base.deliveries.count - recipients = ActionMailer::Base.deliveries.flat_map(&:to) - assert_equal @affected.map(&:email).sort, recipients.sort - - notification = Notification.recent_first.first - assert_equal 'task', notification.notification_type - assert_equal 'task_due_date_changed', notification.event - end - - def test_a_student_in_another_unit_is_not_notified - other = FactoryBot.create(:project) - - change_due_date - - assert_equal 0, Notification.where(user: other.student, event: 'task_due_date_changed').count - end - - def test_a_withdrawn_student_in_the_same_unit_is_not_notified - withdrawn = @unit.projects.find_by(enrolled: false) - assert_not_nil withdrawn, 'guard: the unit factory should include a withdrawn project' - withdrawn.task_for_task_definition(@task_def) # give them a task too - - change_due_date - - assert_equal 0, Notification.where(user: withdrawn.student, event: 'task_due_date_changed').count - end - - def test_it_respects_receive_task_notifications - opted_out = @affected.first - opted_out.update!(receive_task_notifications: false) - - change_due_date - - assert_equal 0, Notification.where(user: opted_out, event: 'task_due_date_changed').count - # everyone else still hears about it - assert_equal @affected.size - 1, ActionMailer::Base.deliveries.count - end - - def test_an_unrelated_update_sends_nothing - assert_no_difference 'Notification.count' do - @task_def.update!(description: 'A new description, unrelated to the due date.') - end - - assert_equal 0, ActionMailer::Base.deliveries.count - end - - def test_the_message_names_the_task_but_not_the_new_date - change_due_date - - notification = Notification.recent_first.first - new_date = @task_def.reload.due_date - - assert_includes notification.message, @task_def.abbreviation - assert_includes notification.message, @unit.code - assert_not_includes notification.message, new_date.strftime('%Y') - end - - def test_the_link_points_at_the_task_on_the_student_dashboard - change_due_date - - notification = Notification.recent_first.first - task = @task_def.tasks.detect { |t| t.project.student.id == notification.user_id } - - assert_equal( - "/projects/#{task.project.id}/dashboard/#{@task_def.abbreviation}", - notification.link - ) - end - - def test_the_event_specific_template_is_used - change_due_date - - assert_includes delivered_body, 'The new due date is not included in this email' - end -end diff --git a/test/sidekiq/task_due_date_changed_notification_job_test.rb b/test/sidekiq/task_due_date_changed_notification_job_test.rb new file mode 100644 index 0000000000..54bc161ee1 --- /dev/null +++ b/test/sidekiq/task_due_date_changed_notification_job_test.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TaskDueDateChangedNotificationJobTest < ActiveSupport::TestCase + EVENT = 'task_due_date_changed' + + setup do + @unit = FactoryBot.create(:unit, task_count: 0) + @task_def = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: 1 + ) + + @previous_due_date = @task_def[:due_date]&.to_date&.iso8601 + changed_due_date = (@task_def.due_date + 1.week).to_date + + @task_def.update!(due_date: changed_due_date) + @new_due_date = changed_due_date.iso8601 + + ActionMailer::Base.deliveries.clear + end + + def test_notifies_every_eligible_student_without_creating_tasks + expected = eligible_projects.count + + assert_operator expected, :>=, 2 + assert_equal 0, @task_def.tasks.count + + assert_difference 'Notification.count', expected do + assert_no_difference 'Task.count' do + run_job + end + end + + assert_equal expected, ActionMailer::Base.deliveries.count + end + + def test_does_not_notify_student_below_target_grade + project = @unit.active_projects.find_by!(target_grade: 0) + + run_job + + refute Notification.exists?( + user: project.student, + event: EVENT + ) + end + + def test_does_not_notify_withdrawn_student + project = @unit.projects.find_by!(enrolled: false) + project.update!(target_grade: 3) + + run_job + + refute Notification.exists?( + user: project.student, + event: EVENT + ) + end + + def test_respects_task_notification_preference + project = eligible_projects.first + project.student.update!(receive_task_notifications: false) + + run_job + + refute Notification.exists?( + user: project.student, + event: EVENT + ) + end + + def test_does_not_notify_for_inactive_unit + @unit.update!(active: false) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_skips_stale_job_after_another_due_date_change + @task_def.update!(due_date: @task_def.due_date + 1.week) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_direct_model_change_does_not_enqueue_job + task_definition = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: 1 + ) + + assert_no_difference( + -> { TaskDueDateChangedNotificationJob.jobs.size } + ) do + task_definition.update!( + due_date: task_definition.due_date + 1.day + ) + end + end + + def test_message_and_link_are_privacy_safe + project = eligible_projects.first + + run_job + + notification = Notification.find_by!( + user: project.student, + event: EVENT + ) + + assert_includes notification.message, @task_def.abbreviation + assert_includes notification.message, @unit.code + refute_includes notification.message, @new_due_date + + assert_equal( + "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}", + notification.link + ) + end + + def test_event_specific_template_is_used + run_job + + assert_includes( + delivered_body, + 'The new due date is not included in this email' + ) + end + + private + + def run_job + TaskDueDateChangedNotificationJob.new.perform( + @task_def.id, + @previous_due_date, + @new_due_date + ) + end + + def eligible_projects + @unit.active_projects.where( + 'projects.target_grade >= ?', + @task_def.target_grade + ) + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + return mail.body.decoded unless mail.multipart? + + mail.parts.map { |part| part.body.decoded }.join("\n") + end +end From 4fb43f3320cc60ae1ac5a365b2a0dff5068bf3f7 Mon Sep 17 00:00:00 2001 From: Kimsreng Date: Sat, 15 Aug 2026 14:25:56 +1000 Subject: [PATCH 052/247] test(notifications): cover mailer rendering for all events --- test/mailers/notifications_mailer_test.rb | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 test/mailers/notifications_mailer_test.rb diff --git a/test/mailers/notifications_mailer_test.rb b/test/mailers/notifications_mailer_test.rb new file mode 100644 index 0000000000..d82b2b49ec --- /dev/null +++ b/test/mailers/notifications_mailer_test.rb @@ -0,0 +1,77 @@ +require 'test_helper' + +# EN-T04: every event's mailer templates render without raising, in both +# HTML and text. +# +# NOTE FOR FUTURE CONTRIBUTORS: this file does not discover events +# automatically. When you add a new event, add its name and notification +# type to the EVENTS hash below. A missing entry here means a broken or +# missing template for that event falls back silently to +# single_notification and nothing catches it. +class NotificationsMailerTest < ActionMailer::TestCase + # event => notification_type, matching the six events currently wired up + # in NotificationService.notify call sites across the app. + EVENTS = { + 'task_comment_created' => 'feedback', + 'extension_assessed' => 'extension', + 'group_membership_changed' => 'general', + 'new_task_available' => 'task', + 'task_due_date_changed' => 'task', + 'task_status_changed' => 'task' + }.freeze + + LINK = '/projects/1/dashboard/A1'.freeze + + EVENTS.each do |event, notification_type| + define_method("test_#{event}_renders_html_and_text") do + notification = FactoryBot.create( + :notification, + notification_type: notification_type, + event: event, + message: "A realistic message for #{event}, long enough to catch interpolation errors.", + link: LINK + ) + + mail = NotificationsMailer.single_notification(notification) + + assert mail.html_part.body.to_s.present?, "#{event}: HTML part did not render" + assert mail.text_part.body.to_s.present?, "#{event}: text part did not render" + end + + define_method("test_#{event}_subject_is_not_blank") do + notification = FactoryBot.create(:notification, notification_type: notification_type, event: event) + + mail = NotificationsMailer.single_notification(notification) + + assert mail.subject.present?, "#{event}: subject was blank" + + # Every event shares one subject today, built at + # app/mailers/notifications_mailer.rb:22. This assertion needs to + # change if anyone adds a per-event subject lookup. + expected_subject = "#{Doubtfire::Application.config.institution[:product_name]}: New notification" + assert_equal expected_subject, mail.subject, "#{event}: subject shape changed" + end + + define_method("test_#{event}_link_is_in_the_body") do + # KNOWN GAP, not a test bug: group_membership_changed.html.erb and + # .text.erb never reference @notification.link, unlike every other + # event template (compare task_comment_created.html.erb). The real + # call site in app/models/group.rb also never passes a link. Same + # underlying issue as flagged on MN-T02 - raised with the lead, + # remove this skip once it's resolved one way or the other. + skip "group_membership_changed template does not render @notification.link — see MN-T02 discussion, needs lead decision" if event == 'group_membership_changed' + + notification = FactoryBot.create( + :notification, + notification_type: notification_type, + event: event, + link: LINK + ) + + mail = NotificationsMailer.single_notification(notification) + + assert_includes mail.html_part.body.to_s, LINK, "#{event}: link missing from HTML body" + assert_includes mail.text_part.body.to_s, LINK, "#{event}: link missing from text body" + end + end +end \ No newline at end of file From 4613e8b72381c80b71d2170b7c88964430867a7d Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 22:29:15 +1000 Subject: [PATCH 053/247] test(notifications): require event-specific mailer templates --- test/mailers/notifications_mailer_test.rb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/mailers/notifications_mailer_test.rb b/test/mailers/notifications_mailer_test.rb index d82b2b49ec..adca778356 100644 --- a/test/mailers/notifications_mailer_test.rb +++ b/test/mailers/notifications_mailer_test.rb @@ -24,6 +24,20 @@ class NotificationsMailerTest < ActionMailer::TestCase EVENTS.each do |event, notification_type| define_method("test_#{event}_renders_html_and_text") do + # The mailer falls back to the generic template when an event-specific + # template is missing, so require both event-specific template files. + %w[html text].each do |format| + template_path = Rails.root.join( + 'app', + 'views', + 'notifications_mailer', + "#{event}.#{format}.erb" + ) + + assert template_path.file?, + "#{event}: missing event-specific #{format} template" + end + notification = FactoryBot.create( :notification, notification_type: notification_type, @@ -74,4 +88,4 @@ class NotificationsMailerTest < ActionMailer::TestCase assert_includes mail.text_part.body.to_s, LINK, "#{event}: link missing from text body" end end -end \ No newline at end of file +end From 5138d4cd1356a846753559fc6afb6a79aa1aab9b Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 15 Aug 2026 23:42:33 +1000 Subject: [PATCH 054/247] docs(pwa): clarify route-specific offline behaviour --- docs/notifications/pwa-offline.md | 332 +++++++++++++++++++++--------- 1 file changed, 236 insertions(+), 96 deletions(-) diff --git a/docs/notifications/pwa-offline.md b/docs/notifications/pwa-offline.md index 9b118370dc..bcaf16252f 100644 --- a/docs/notifications/pwa-offline.md +++ b/docs/notifications/pwa-offline.md @@ -1,15 +1,95 @@ # PWA offline behaviour -What a user sees if they lose connection while using OnTrack, and why. +This document records what users experience when OnTrack loses network +connectivity and explains the relevant Angular service-worker configuration. -## What is cached, and what is not +No caching configuration or application behaviour was changed as part of this +documentation-only investigation. -`ngsw-config.json` (doubtfire-web) has two kinds of cache group. The `app` and -`assets` asset groups cache the static shell — `index.html`, the compiled JS -and CSS bundles, and images — with `installMode: prefetch`, so the shell -downloads up front. That part of the PWA is designed to work offline. +## Configuration under test -The `api` data group is deliberately excluded: +Testing used the generated static development build from the +`doubtfire-web/feature/notifications` branch. + +Before the final offline tests, the environment confirmed that: + +- `navigator.serviceWorker.controller` referenced `ngsw-worker.js`. +- `/ngsw/state` reported `Driver state: NORMAL ((nominal))`. +- `/index.html` existed in the versioned `app` asset cache. +- **Bypass for network** was disabled. +- **Update on reload** was disabled. +- Ordinary reloads were used rather than forced or hard refreshes. + +The generated static build was used for the final test so the service worker +could install and cache the built application files consistently. + +## What is cached + +### Application shell + +The `app` asset group uses `installMode: "prefetch"` and includes: + +- `/index.html` +- the compiled JavaScript bundles +- the compiled CSS bundles +- the favicon +- the web application manifest + +These resources are downloaded when the service-worker application version is +installed. + +The `assets` asset group uses `installMode: "lazy"`. Matching images, fonts, +and other assets are cached after they are requested rather than all being +downloaded during installation. + +### Navigation URLs + +The service-worker configuration contains these navigation rules: + +```json +[ + "/**", + "!/**/*.*", + "!/**/*__*", + "!/**/*__*/**", + "!/JPlag/**", + "!/JPlag", + "!/sidekiq/**", + "!/sidekiq", + "!/beta/**", + "!/beta", + "!/legacy", + "!/legacy/**" +] +``` + +A URL must match a positive rule and must not match any negative rule to be +treated as an Angular navigation request. + +The `!/**/*.*` rule is intended to exclude file URLs whose final path segment +contains a file extension. However, it also excludes valid OnTrack task routes +when the task abbreviation contains a period. + +For example: + +- `/` is treated as a navigation request. +- `/projects/2/dashboard` is treated as a navigation request. +- `/projects/28/dashboard/A15` is treated as a navigation request. +- `/projects/2/dashboard/2.2P` is not treated as a navigation request because + its final path segment contains a period. + +No `navigationRequestStrategy` override is configured. Angular therefore uses +its default `performance` navigation strategy for matching navigation +requests. This strategy serves the configured `/index.html`, which is normally +available from the application cache. + +An excluded URL is not redirected to the cached index file. When the network +is unavailable, the excluded request cannot be completed and may produce the +browser's native error page. + +### API data + +The `api` data group uses the following policy: ```json { @@ -23,102 +103,162 @@ The `api` data group is deliberately excluded: } ``` -`maxSize: 0` means the cache holds zero entries, so there is never anything -to fall back to. `strategy: freshness` is network-first, and with nothing -cached, a failed network request has no cached response behind it — it just -fails. In practice: **no API response is ever served from the service -worker's cache, under any condition.** - -### The maxSize 0 choice - -This traces back to commit `ab5a30a`, "FIX: Ensure api/ data is not cached" -(2020). The commit message does not elaborate further than that, but the -reasoning is not hard to infer: API responses here are grades, task status, -submissions and extension state — exactly the data where showing something -stale would be actively misleading, not just inconvenient. A short-TTL cache -would still risk a tutor or student acting on an out-of-date number. Opting -API traffic out of the cache entirely avoids that risk, at the cost of any -offline API access at all. If a more specific justification than this exists, -it hasn't been written down anywhere in the codebase — worth confirming with -the lead if it matters for a future decision. - -No caching behaviour was changed to investigate this ticket. The above is a -description of the existing config, not a proposal. - -## What actually happens offline - -Tested in Chrome DevTools (Network tab → Offline), against the dev stack with -the service worker confirmed active and controlling the page (`Application` → -`Service Workers` showed `ngsw-worker.js` "activated and running" before each -test below). - -### Scenario 1: reloading a page while offline - -Navigating to `localhost:4200/projects/28/dashboard/A15` and reloading while -offline does not show any OnTrack UI. Chrome shows its own native offline -page — "This page isn't working, localhost took too long to respond, HTTP -ERROR 504." The network log confirms the top-level document request itself -failed outright rather than being served from the service worker's cached -shell. - -So despite the service worker being registered and running, a hard reload -while offline does not fall back to a cached app shell. The user gets a -generic browser error with no indication it's OnTrack-specific, and no way -to retry from within the app. - -### Scenario 2: losing connection mid-session - -More realistic: the app is already loaded and the user goes offline without -reloading, then navigates to a task they haven't opened yet in that session -(client-side routing, no full page load). - -Here the shell and anything already in memory stay up — the task list -sidebar, and top-level task fields (title, due date, status) that were part -of an earlier list fetch, render fine. But the secondary fetches that page -needs (`prerequisites`, `submission_details`, `comments`) mostly fail. Some -of the same-looking requests returned `200`/`304` and some returned `504` or -failed outright — the successes are ordinary browser HTTP cache hits for -URLs already fetched earlier in the session, not the service worker's own -data cache, which is disabled by `maxSize: 0`. Anything not already fetched -before going offline has nothing to fall back to and fails. - -When a fetch fails, the app does not degrade gracefully. It surfaced this to -the user as a toast: +`freshness` is a network-first strategy. The zero maximum size and zero maximum +age indicate that matching responses are not intended to provide a reusable +offline API cache. + +Users should therefore not rely on grades, task state, submission details, +comments, prerequisites, or other API-backed information being available +after connectivity is lost. + +This configuration should not be described as proof that an API response can +never be written to or returned from a service-worker cache under any +condition. Its practical effect and intent are that API data should not be +relied upon for meaningful offline reuse. + +A successful response status such as `200` or `304` does not, by itself, +identify where the response came from. The Network panel's **Size**, +**Transferred**, and **Initiator** information must be inspected before +identifying a response as coming from the service worker, memory cache, disk +cache, or network. + +## Observed offline behaviour + +### Included navigation route + +An ordinary offline reload of `/` loaded the cached Angular application shell +rather than Chrome's native `HTTP ERROR 504` page. + +Static resources such as the favicon and application icon were returned by the +service worker. Authentication, API, analytics, and other uncached requests +failed while the browser was offline. + +The application shell could therefore load, but API-backed application state +was unavailable or incomplete. This confirms that caching the shell does not +provide a complete offline mode. + +### Dotted task route + +An ordinary offline reload of: + +```text +/projects/2/dashboard/2.2P +``` + +produced Chrome's native `HTTP ERROR 504` page. + +Before this test: + +- `ngsw-worker.js` controlled the page. +- The service-worker driver state was normal. +- `/index.html` existed in the application cache. +- The reload was an ordinary reload rather than a forced refresh. + +The route was excluded from Angular navigation handling because its final +segment, `2.2P`, contains a period and matches the `!/**/*.*` negative rule. + +The service worker therefore did not use cached `/index.html` as the +navigation fallback. With the network unavailable, the route request failed +and Chrome displayed its native error page. + +This is a route-specific navigation limitation. It is not evidence that the +application shell was missing from the service-worker cache. + +### Losing connectivity mid-session + +When connectivity was removed without reloading, the already-loaded +application shell and information held in memory remained visible. + +Requests for new API-backed information failed. This included secondary task +requests such as prerequisite, submission-detail, and comment requests when +the information had not already been loaded. + +One observed failure produced this toast: > Failed to fetch prerequisites for task definition: TypeError: Cannot read > properties of null (reading 'error') -That's an unhandled null dereference, not an offline message — the error -handling path assumes a response body is always present and throws when -there isn't one. A user sees a confusing technical error rather than -anything telling them they're offline. +This is a technical error rather than a clear explanation that the application +has lost network connectivity. + +Some previously requested resources may still return successful statuses while +offline. Their source must be confirmed using the Network panel rather than +being inferred from the status code alone. ## Summary -The `api` group's no-cache config guarantees a user is never shown stale -academic data, which is clearly the intent. The tradeoff is that there is no -designed offline mode at all: depending on whether they reload or just keep -navigating, a disconnected user gets either a browser-level 504 page or a -raw JS error toast. Neither tells them they're offline, and neither offers a -retry. +OnTrack caches its Angular application shell, but offline reload behaviour is +route-dependent. + +Routes that satisfy the configured navigation rules can receive cached +`/index.html`. Valid task routes whose final path segment contains a period are +excluded by `!/**/*.*` and can produce a browser-level `504` when reloaded +offline. + +Even when the shell loads, API-backed information cannot be relied upon +offline. Losing connectivity can therefore leave the application shell visible +while new data requests fail and technical errors are displayed. + +The current behaviour can result in three different user experiences: + +1. An included navigation route loads the cached application shell, but + API-backed information is missing or fails. +2. A dotted task route produces Chrome's native `504` page because it is + excluded from navigation fallback. +3. Losing connectivity during an existing session leaves the shell visible + but can produce failed requests and technical error messages. ## Recommendation -Out of scope here — this ticket is documentation only, no caching behaviour -was changed. If the team wants an actual offline UX (a banner, a clear -"you're offline, reconnect to continue" state, or handling the null response -case without throwing), that belongs in a separate ticket. - -## How to check it by hand - -1. Open the app, sign in, and confirm the service worker is active: - DevTools → `Application` → `Service Workers` → status should read - "activated and is running." -2. **Reload case:** DevTools → `Network` → set throttling to `Offline`, - then reload the page. Expect Chrome's native offline error page, not - OnTrack. -3. **Mid-session case:** with the app already loaded, switch to `Offline` - without reloading, then click into a task not yet opened this session. - Expect the shell to stay up, task list data already in memory to render, - and any new data fetch (prerequisites, submission details, comments) to - fail — watch for an unhandled error toast rather than an offline message. \ No newline at end of file +A separate `doubtfire-web` ticket should investigate narrowing or replacing +the `!/**/*.*` navigation exclusion so valid task abbreviations containing +periods can use the Angular navigation fallback without treating genuine +static-file requests as application routes. + +A separate ticket should also consider: + +- displaying an offline or disconnected status; +- replacing technical request errors with user-friendly messages; +- safely handling absent network responses; +- providing a retry path after connectivity returns; and +- identifying which information, if any, should be available offline. + +These changes are outside the scope of this documentation-only ticket. + +## How to verify manually + +1. Build and serve the generated Angular output. +2. Load OnTrack while online. +3. Wait until the service worker is ready. +4. Confirm that + `navigator.serviceWorker.controller?.scriptURL` + ends in `ngsw-worker.js`. +5. Confirm that `/ngsw/state` reports + `Driver state: NORMAL ((nominal))`. +6. Confirm that `/index.html` exists in the versioned `app` asset cache. +7. Ensure **Bypass for network** is disabled. +8. Ensure **Update on reload** is disabled. +9. Load `/` while online. +10. Set Network throttling to **Offline**. +11. Use ordinary Command+R and confirm that the cached Angular shell loads. +12. Do not use Shift+Command+R or **Empty cache and hard reload**. +13. Return Network throttling to **No throttling**. +14. Load `/projects/2/dashboard/2.2P`. +15. Set Network throttling back to **Offline**. +16. Use ordinary Command+R. +17. Confirm that the dotted task route produces Chrome's native `504` page. +18. Return online and load a task. +19. Remove connectivity without reloading. +20. Navigate to information that has not already been requested. +21. Record the failed API requests and any user-facing error. +22. Use the Network panel's **Size**, **Transferred**, and **Initiator** + information before attributing successful responses to a particular cache. + +## Relevant configuration + +The behaviour described here is controlled primarily by: + +- `doubtfire-web/ngsw-config.json` +- the generated `ngsw.json` service-worker manifest +- Angular's service-worker navigation request handling +- the application's handling of failed API requests From d286b89b0fb60c22dbc292fe09cde4b0303561a0 Mon Sep 17 00:00:00 2001 From: Kimsreng Date: Sat, 15 Aug 2026 14:56:39 +1000 Subject: [PATCH 055/247] test(notifications): assert every event produces a valid push payload --- app/models/group.rb | 3 +- .../events/group_membership_changed.md | 4 ++- test/helpers/push_notification_helper.rb | 32 +++++++++++++++++++ test/models/notification_extension_test.rb | 25 +++++++++++---- test/models/notification_group_test.rb | 11 ++++++- test/models/notification_new_task_test.rb | 12 +++++-- test/models/notification_task_comment_test.rb | 9 ++++++ test/models/notification_task_status_test.rb | 9 ++++++ ..._due_date_changed_notification_job_test.rb | 15 ++++++--- 9 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 test/helpers/push_notification_helper.rb diff --git a/app/models/group.rb b/app/models/group.rb index 3aa9cb36c6..a992525cc8 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -192,7 +192,8 @@ def notify_group_membership_change(project, change) user: student, type: 'general', event: 'group_membership_changed', - message: "You have been #{change} group #{name} in #{unit.code}." + message: "You have been #{change} group #{name} in #{unit.code}.", + link: "/projects/#{project.id}/groups" ) rescue StandardError => e logger.error( diff --git a/docs/notifications/events/group_membership_changed.md b/docs/notifications/events/group_membership_changed.md index b16d3ad653..19c9827764 100644 --- a/docs/notifications/events/group_membership_changed.md +++ b/docs/notifications/events/group_membership_changed.md @@ -34,6 +34,7 @@ The event uses: - `type: 'general'` - `event: 'group_membership_changed'` - recipient: `project.student` +- `link: "/projects/#{project.id}/groups"` Event-specific HTML and text email templates are provided under `app/views/notifications_mailer/`. @@ -57,4 +58,5 @@ The tests cover: - other group members are not notified - `switch_to_tutorial` does not send a leave-then-join notification pair - a notification failure does not stop the membership change -- bulk CSV imports add students without raising per-student notifications \ No newline at end of file +- bulk CSV imports add students without raising per-student notifications +- the push payload points to the affected project's group page diff --git a/test/helpers/push_notification_helper.rb b/test/helpers/push_notification_helper.rb new file mode 100644 index 0000000000..bfe1839edf --- /dev/null +++ b/test/helpers/push_notification_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module TestHelpers + module PushNotificationHelper + def parsed_push_notification(notification) + JSON.parse( + PushNotificationService.payload_for(notification) + ).fetch('notification') + end + + def assert_valid_push_payload(notification, expected_link:) + push = parsed_push_notification(notification) + data = push.fetch('data') + expected_body = notification.message.to_s.truncate( + PushNotificationService::MAX_BODY_LENGTH + ) + + assert push['title'].present?, 'push title must be present' + assert_equal expected_body, push['body'] + assert_operator( + push['body'].length, + :<=, + PushNotificationService::MAX_BODY_LENGTH + ) + assert_equal notification.id, data['notification_id'] + assert_equal expected_link, data['link'] + assert data['link'].present?, 'push data.link must be present' + + push + end + end +end diff --git a/test/models/notification_extension_test.rb b/test/models/notification_extension_test.rb index 2f0b709dd8..a1b59e8ea5 100644 --- a/test/models/notification_extension_test.rb +++ b/test/models/notification_extension_test.rb @@ -3,6 +3,11 @@ # EN-E03: assessing an extension request notifies the student. class NotificationExtensionTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + EXTENSION_REQUEST_TEXT = + 'Private extension request text that must stay inside OnTrack.'.freeze + setup do ActionMailer::Base.deliveries.clear @@ -21,7 +26,7 @@ class NotificationExtensionTest < ActiveSupport::TestCase def create_extension_request @task.apply_for_extension( @student, - 'Please grant me an extension.', + EXTENSION_REQUEST_TEXT, 1 ) end @@ -50,6 +55,12 @@ def test_granted_extension_notifies_student_with_new_date assert_equal @student, notification.user assert_equal 'extension', notification.notification_type assert_equal 'extension_assessed', notification.event + push = assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}" + ) + assert_not_includes notification.message, EXTENSION_REQUEST_TEXT + assert_not_includes push['body'], EXTENSION_REQUEST_TEXT assert extension.extension_granted assert_includes notification.message, 'Extension granted' @@ -83,7 +94,7 @@ def test_denied_extension_notifies_student extension.reload notification = Notification.recent_first.first - refute extension.extension_granted + assert_not extension.extension_granted assert_equal @student, notification.user assert_equal 'extension', notification.notification_type @@ -145,13 +156,13 @@ def test_failed_grant_does_not_assess_or_notify_student assert_empty ActionMailer::Base.deliveries assert_includes extension.errors[:extension], 'could not be applied' - refute extension.assessed? - refute extension.extension_granted + assert_not extension.assessed? + assert_not extension.extension_granted assert_equal original_extensions, task.reload.extensions extension.reload - refute extension.assessed? - refute extension.extension_granted + assert_not extension.assessed? + assert_not extension.extension_granted end def test_extension_notification_uses_event_specific_templates @@ -164,4 +175,4 @@ def test_extension_notification_uses_event_specific_templates assert_includes parts[:html], 'Your extension request has been assessed' assert_includes parts[:text], 'Your extension request has been assessed' end -end \ No newline at end of file +end diff --git a/test/models/notification_group_test.rb b/test/models/notification_group_test.rb index 4cdd168a80..c95245ae20 100644 --- a/test/models/notification_group_test.rb +++ b/test/models/notification_group_test.rb @@ -4,6 +4,8 @@ # EN-V05: notify only the affected student when group membership changes. class NotificationGroupTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + setup do ActionMailer::Base.deliveries.clear @@ -29,7 +31,14 @@ def test_adding_a_member_notifies_only_that_student assert_equal @student, notification.user assert_equal 'general', notification.notification_type assert_equal 'group_membership_changed', notification.event - assert_includes notification.message, 'added to' + assert_equal( + "You have been added to group #{@group.name} in #{@project.unit.code}.", + notification.message + ) + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/groups" + ) assert_equal 1, ActionMailer::Base.deliveries.count assert_equal [@student.email], ActionMailer::Base.deliveries.last.to diff --git a/test/models/notification_new_task_test.rb b/test/models/notification_new_task_test.rb index a3d67d5108..cb2fa0494f 100644 --- a/test/models/notification_new_task_test.rb +++ b/test/models/notification_new_task_test.rb @@ -5,6 +5,8 @@ # EN-V02: newly available tasks notify eligible students. class NotificationNewTaskTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + setup do ActionMailer::Base.deliveries.clear @@ -79,13 +81,19 @@ def test_available_task_notifies_eligible_student assert_equal 'task', notification.notification_type assert_equal 'new_task_available', notification.event - assert_includes notification.message, @task_definition.abbreviation - assert_includes notification.message, @unit.code + assert_equal( + "A new task is available: #{@task_definition.abbreviation} in #{@unit.code}.", + notification.message + ) assert_equal( "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}", notification.link ) + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}" + ) assert_equal 1, ActionMailer::Base.deliveries.count assert_equal [@student.email], ActionMailer::Base.deliveries.last.to diff --git a/test/models/notification_task_comment_test.rb b/test/models/notification_task_comment_test.rb index 48db8a6ff0..c39bc8091f 100644 --- a/test/models/notification_task_comment_test.rb +++ b/test/models/notification_task_comment_test.rb @@ -3,6 +3,8 @@ # EN-E01: posting a task comment notifies the other party. class NotificationTaskCommentTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + setup do ActionMailer::Base.deliveries.clear @@ -34,6 +36,10 @@ def test_a_tutor_comment_notifies_the_student assert_equal @student, notification.user assert_equal 'feedback', notification.notification_type assert_equal 'task_comment_created', notification.event + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}" + ) assert_equal 1, ActionMailer::Base.deliveries.count assert_equal [@student.email], ActionMailer::Base.deliveries.last.to end @@ -68,9 +74,12 @@ def test_the_comment_text_is_not_in_the_notification_or_the_email notification = Notification.recent_first.first body = delivered_body + push = parsed_push_notification(notification) + assert_not_empty body, 'guard: the body must be readable or this test proves nothing' assert_not_includes notification.message, secret assert_not_includes body, secret + assert_not_includes push['body'], secret end def test_the_message_names_the_commenter_and_the_task diff --git a/test/models/notification_task_status_test.rb b/test/models/notification_task_status_test.rb index aa738fed2b..68004e3474 100644 --- a/test/models/notification_task_status_test.rb +++ b/test/models/notification_task_status_test.rb @@ -4,6 +4,8 @@ # EN-E02: a staff status change notifies the student. A student's own action # does not. class NotificationTaskStatusTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + setup do ActionMailer::Base.deliveries.clear @@ -41,6 +43,10 @@ def test_a_staff_status_change_notifies_the_student assert_equal @student, notification.user assert_equal 'task', notification.notification_type assert_equal 'task_status_changed', notification.event + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard/#{@task_definition.abbreviation}" + ) assert_equal 1, ActionMailer::Base.deliveries.count assert_equal [@student.email], ActionMailer::Base.deliveries.last.to end @@ -81,9 +87,12 @@ def test_the_status_value_is_not_in_the_notification_or_the_email notification = Notification.recent_first.first body = delivered_body + push = parsed_push_notification(notification) + assert_not_empty body, 'guard: the body must be readable or this test proves nothing' assert_not_includes notification.message, 'Discuss' assert_not_includes body, 'Discuss' + assert_not_includes push['body'], 'Discuss' end def test_the_message_names_the_actor_and_the_task diff --git a/test/sidekiq/task_due_date_changed_notification_job_test.rb b/test/sidekiq/task_due_date_changed_notification_job_test.rb index 54bc161ee1..23517c83db 100644 --- a/test/sidekiq/task_due_date_changed_notification_job_test.rb +++ b/test/sidekiq/task_due_date_changed_notification_job_test.rb @@ -3,6 +3,8 @@ require 'test_helper' class TaskDueDateChangedNotificationJobTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + EVENT = 'task_due_date_changed' setup do @@ -42,7 +44,7 @@ def test_does_not_notify_student_below_target_grade run_job - refute Notification.exists?( + assert_not Notification.exists?( user: project.student, event: EVENT ) @@ -54,7 +56,7 @@ def test_does_not_notify_withdrawn_student run_job - refute Notification.exists?( + assert_not Notification.exists?( user: project.student, event: EVENT ) @@ -66,7 +68,7 @@ def test_respects_task_notification_preference run_job - refute Notification.exists?( + assert_not Notification.exists?( user: project.student, event: EVENT ) @@ -116,12 +118,17 @@ def test_message_and_link_are_privacy_safe assert_includes notification.message, @task_def.abbreviation assert_includes notification.message, @unit.code - refute_includes notification.message, @new_due_date + assert_not_includes notification.message, @new_due_date assert_equal( "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}", notification.link ) + push = assert_valid_push_payload( + notification, + expected_link: "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}" + ) + assert_not_includes push['body'], @new_due_date end def test_event_specific_template_is_used From 54e25991a5156864e41983ac6f7e5e3350628158 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 16 Aug 2026 12:56:31 +1000 Subject: [PATCH 056/247] fix(deps): apply Rails 8.0.x security patches --- Gemfile | 2 +- Gemfile.lock | 110 ++++++++++++++++++++++++++------------------------- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/Gemfile b/Gemfile index b367b82225..fc3a2c9380 100644 --- a/Gemfile +++ b/Gemfile @@ -13,7 +13,7 @@ ruby_versions = { ruby ruby_versions[(ENV['RAILS_ENV'] || 'development').to_sym] # The venerable, almighty Rails -gem 'rails', '~>8.0' +gem 'rails', '~> 8.0.0', '>= 8.0.5.1' group :development, :test do gem 'better_errors' diff --git a/Gemfile.lock b/Gemfile.lock index 9df7ab4c0a..11f0c46199 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,29 +2,29 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (2.0.1) - actioncable (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + actioncable (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actionmailbox (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) mail (>= 2.8.0) - actionmailer (8.0.2) - actionpack (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activesupport (= 8.0.2) + actionmailer (8.0.5.1) + actionpack (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activesupport (= 8.0.5.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2) - actionview (= 8.0.2) - activesupport (= 8.0.2) + actionpack (8.0.5.1) + actionview (= 8.0.5.1) + activesupport (= 8.0.5.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -32,35 +32,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2) - actionpack (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + actiontext (8.0.5.1) + actionpack (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2) - activesupport (= 8.0.2) + actionview (8.0.5.1) + activesupport (= 8.0.5.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.2) - activesupport (= 8.0.2) + activejob (8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.3.6) - activemodel (8.0.2) - activesupport (= 8.0.2) - activerecord (8.0.2) - activemodel (= 8.0.2) - activesupport (= 8.0.2) + activemodel (8.0.5.1) + activesupport (= 8.0.5.1) + activerecord (8.0.5.1) + activemodel (= 8.0.5.1) + activesupport (= 8.0.5.1) timeout (>= 0.4.0) - activestorage (8.0.2) - actionpack (= 8.0.2) - activejob (= 8.0.2) - activerecord (= 8.0.2) - activesupport (= 8.0.2) + activestorage (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activesupport (= 8.0.5.1) marcel (~> 1.0) - activesupport (8.0.2) + activesupport (8.0.5.1) base64 benchmark (>= 0.3) bigdecimal @@ -327,20 +327,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2) - actioncable (= 8.0.2) - actionmailbox (= 8.0.2) - actionmailer (= 8.0.2) - actionpack (= 8.0.2) - actiontext (= 8.0.2) - actionview (= 8.0.2) - activejob (= 8.0.2) - activemodel (= 8.0.2) - activerecord (= 8.0.2) - activestorage (= 8.0.2) - activesupport (= 8.0.2) + rails (8.0.5.1) + actioncable (= 8.0.5.1) + actionmailbox (= 8.0.5.1) + actionmailer (= 8.0.5.1) + actionpack (= 8.0.5.1) + actiontext (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activemodel (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) bundler (>= 1.15.0) - railties (= 8.0.2) + railties (= 8.0.5.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -358,13 +358,14 @@ GEM json require_all (~> 3.0) ruby-progressbar - railties (8.0.2) - actionpack (= 8.0.2) - activesupport (= 8.0.2) + railties (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) @@ -536,6 +537,7 @@ GEM thor (1.3.2) tilt (2.6.0) timeout (0.4.3) + tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) typhoeus (1.4.1) @@ -602,7 +604,7 @@ DEPENDENCIES pdf-reader puma rack-cors - rails (~> 8.0) + rails (~> 8.0.0, >= 8.0.5.1) rails-latex rails_best_practices redis From 57922d78e090956df6c04fe932572d5c77fea07b Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 16 Aug 2026 16:40:33 +1000 Subject: [PATCH 057/247] fix(deps): update Rack and Rack Session --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 11f0c46199..0f31e5ba99 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -317,10 +317,10 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.12) + rack (3.1.22) rack-cors (2.0.2) rack (>= 2.0.0) - rack-session (2.1.0) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) From 0c9420ca70ebc6a871603115707d0a9697442354 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 16 Aug 2026 21:53:14 +1000 Subject: [PATCH 058/247] fix(deps): update OAuth2 JWT and Ruby SAML --- Gemfile.lock | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0f31e5ba99..267f4b40d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,7 +78,11 @@ GEM aes_key_wrap (1.1.0) afm (0.2.2) amq-protocol (2.3.3) + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) ast (2.4.3) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) backport (1.2.0) base64 (0.2.0) bcrypt (3.1.20) @@ -222,7 +226,7 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects - jwt (2.10.1) + jwt (2.10.3) base64 kramdown (2.5.1) rexml (>= 3.3.9) @@ -284,13 +288,16 @@ GEM nokogiri (1.18.7-x86_64-linux-gnu) racc (~> 1.4) numerizer (0.1.1) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) multi_xml (~> 0.5) rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) observer (0.1.2) orm_adapter (0.5.0) ostruct (0.6.1) @@ -451,7 +458,7 @@ GEM ruby-ole (1.2.13.1) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) - ruby-saml (1.18.0) + ruby-saml (1.18.1) nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) @@ -491,9 +498,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) + snaky_hash (2.0.7) + hashie (>= 0.1.0, < 6) + version_gem (~> 1.1, >= 1.1.14) solargraph (0.53.4) backport (~> 1.2) benchmark @@ -550,7 +557,7 @@ GEM unicode-emoji (4.0.4) uri (1.0.3) useragent (0.16.11) - version_gem (1.1.6) + version_gem (1.1.15) warden (1.2.9) rack (>= 2.0.9) webmock (3.25.1) From 3cdfb6f4539120bf0a1069f9c7cfbded0665f71d Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 17 Aug 2026 02:25:59 +1000 Subject: [PATCH 059/247] fix(deps): update Faraday and Addressable --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 267f4b40d3..4ce36e5f3f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,8 +73,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) afm (0.2.2) amq-protocol (2.3.3) @@ -169,7 +169,7 @@ GEM railties (>= 5.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.12.2) + faraday (2.14.3) faraday-net_http (>= 2.0, < 3.5) json logger From efc8fa43b85145c9e8d02342e61e2d91f80ee5b0 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Mon, 17 Aug 2026 14:14:18 +1000 Subject: [PATCH 060/247] fix(deps): upgrade Puma to 7.2.1 --- .gitignore | 5 +++-- Gemfile | 2 +- Gemfile.lock | 4 ++-- bin/rails | 4 ++++ 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100755 bin/rails diff --git a/.gitignore b/.gitignore index 35a46c3ae7..cc587aa820 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ # Ignore locally installed gems /vendor/bundle/ -# Ignore the bin folder made with the app:update:bin task -/bin +# Ignore generated binstubs except the Rails launcher required by Rails/Puma restart. +/bin/* +!/bin/rails # Ignore the default SQLite database. /db/*.sqlite3 diff --git a/Gemfile b/Gemfile index fc3a2c9380..94486337ab 100644 --- a/Gemfile +++ b/Gemfile @@ -48,7 +48,7 @@ end gem 'mysql2' # Webserver - included in development and test and optionally in production -gem 'puma' +gem 'puma', '~> 7.2', '>= 7.2.1' gem 'bootsnap', require: false gem 'csv' diff --git a/Gemfile.lock b/Gemfile.lock index 4ce36e5f3f..da25b5b6df 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -320,7 +320,7 @@ GEM date stringio public_suffix (6.0.1) - puma (6.6.0) + puma (7.2.1) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) @@ -609,7 +609,7 @@ DEPENDENCIES net-smtp oauth2 pdf-reader - puma + puma (~> 7.2, >= 7.2.1) rack-cors rails (~> 8.0.0, >= 8.0.5.1) rails-latex diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000000..efc0377492 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" From 560d665fdfa6b9bfe73522ef130b347f2d3ee23f Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Mon, 17 Aug 2026 15:44:21 +1000 Subject: [PATCH 061/247] feat(notifications): add due soon reminder job Every other event in this feature hangs off something a person did. A deadline getting closer is nobody doing anything, so there is no model to hook and it has to be swept for. SendDueSoonRemindersJob runs daily at 8am and reminds a student about a task due within three days that they still owe work on. Three days is long enough to act on over a weekend and short enough that it is about this task rather than the rest of the trimester. Daily and at a fixed time because a deadline only moves once a day, and because that also fixes when the emails land: on a shorter interval a student gets theirs at whatever hour their task crossed into the window. The hour follows TZ in the process environment, which development/api.env sets to Australia/Melbourne. Not sending twice is the hard part. The job runs again tomorrow and the task is still due soon tomorrow, so a Notification.exists? guard on user, type, event and link means one reminder per student per task. The index on (user_id, event) is what makes that cheap. Retrying the whole sweep is safe for the same reason. Recipients come from projects, not from Task rows: OnTrack creates a row the first time anyone touches the task, so the students who have not started have no row and they are the ones a reminder is for. The job never calls task_for_task_definition, which creates the row it cannot find, nor task_definitions_and_status, which calls it and runs two queries per definition per project. Definitions are read once per unit and tasks once per project. Per student deadlines come from Webcal.end_date_for_task_definition, the app's existing answer to this and what the calendar feed shows, so extensions and a flexible unit's grade level overrides are handled and cannot drift from what the student sees. discuss and demonstrate are excluded. Both mean the student has submitted and is waiting on a tutor. A failure on one project is logged and collected, and the collected ids are raised at the end so Sidekiq retries. Swallowing them would leave perform successful, and a task due today is filtered out as overdue tomorrow. Not verified end to end: there is no Sidekiq worker in the dev stack (EN-F03), so the schedule entry has not been seen to fire. Tests: 18 runs, 64 assertions, 0 failures, 0 errors. --- app/sidekiq/send_due_soon_reminders_job.rb | 171 ++++++++++++ .../task_due_soon.html.erb | 22 ++ .../task_due_soon.text.erb | 12 + config/schedule.yml | 22 ++ docs/notifications/events/task_due_soon.md | 175 ++++++++++++ test/sidekiq/scheduled_job_test.rb | 3 +- .../send_due_soon_reminders_job_test.rb | 262 ++++++++++++++++++ 7 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 app/sidekiq/send_due_soon_reminders_job.rb create mode 100644 app/views/notifications_mailer/task_due_soon.html.erb create mode 100644 app/views/notifications_mailer/task_due_soon.text.erb create mode 100644 docs/notifications/events/task_due_soon.md create mode 100644 test/sidekiq/send_due_soon_reminders_job_test.rb diff --git a/app/sidekiq/send_due_soon_reminders_job.rb b/app/sidekiq/send_due_soon_reminders_job.rb new file mode 100644 index 0000000000..c72048c9d7 --- /dev/null +++ b/app/sidekiq/send_due_soon_reminders_job.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# Remind students about work that is nearly due. +# +# Every other notification in this feature hangs off something a person did: a +# comment was posted, a due date was edited, a status changed. A deadline +# approaching is nobody doing anything, so there is no model to hook and this +# has to be swept for on a schedule. config/schedule.yml runs it. +# +# Recipients come from projects and not from Task rows. OnTrack creates a Task +# row the first time anyone touches the task, so the students who have not +# started have no row, and they are exactly the ones a reminder is for. +# +# Nothing here may call Project#task_for_task_definition, which creates the row +# it cannot find, and nothing may call Project#task_definitions_and_status +# either, because that calls it. This reads project.tasks once per project and +# looks the row up in a hash instead. +class SendDueSoonRemindersJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'task_due_soon' + TYPE = 'task' + + # How far ahead counts as soon, in days. + # + # Three, which is long enough to still do something about it over a weekend + # and short enough that the reminder is about this task rather than about the + # rest of the trimester. Project#top_tasks uses seven for the same idea, and + # seven days of warning on a weekly task is most of the tasks a student has, + # which is a list rather than a reminder. + WINDOW_DAYS = 3 + + # The statuses that mean the student still owes work. + # + # :discuss and :demonstrate are deliberately out. Both mean the student has + # submitted and is waiting on a tutor, so telling them their task is due soon + # is both wrong and the kind of wrong that makes people stop reading + # notifications. Everything past those, complete and fail and the rest, is + # finished with as far as a deadline is concerned. + OUTSTANDING_STATUSES = %i[ + not_started + working_on_it + need_help + fix_and_resubmit + redo + ].freeze + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['send-due-soon-reminders'] }, + on_conflict: :reject, + retry: 1 + + def perform + today = Time.zone.today + horizon = today + WINDOW_DAYS.days + failed_project_ids = [] + + # Units first and then their projects, the same shape as + # NewTaskAvailableNotificationJob, so the unit and its task definitions are + # loaded once per cohort rather than once per student. + Unit.where(active: true).find_each(batch_size: BATCH_SIZE) do |unit| + remind_unit(unit, today, horizon, failed_project_ids) + end + + return if failed_project_ids.empty? + + # Collected and re-raised at the end rather than swallowed, which is what + # NewTaskAvailableNotificationJob does and for the same reason. Logging and + # carrying on would leave perform successful, Sidekiq would schedule no + # retry, and a student whose task is due today is filtered out as overdue + # tomorrow, so that reminder is gone for good. Re-running the whole sweep is + # safe because of the duplicate guard in notify. + raise "Due-soon reminders failed for projects: #{failed_project_ids.join(', ')}" + end + + private + + def remind_unit(unit, today, horizon, failed_project_ids) + # Read once for the whole cohort. Asking per project is where the query + # count runs away: five hundred students against twenty task definitions is + # five hundred of the same query. + task_definitions = unit.task_definitions.to_a + return if task_definitions.empty? + + unit.active_projects + .where.not(target_grade: nil) + .includes(:user) + .find_each(batch_size: BATCH_SIZE) do |project| + remind_project(project, unit, task_definitions, today, horizon) + rescue StandardError => e + failed_project_ids << project.id + + Rails.logger.error( + "Failed due-soon reminders for Project #{project.id}: #{e.class} - #{e.message}" + ) + end + end + + def remind_project(project, unit, task_definitions, today, horizon) + # One query for this student's rows, then look each one up. The row is only + # read, never created. + tasks = project.tasks.includes(:task_status, :task_definition).index_by(&:task_definition_id) + + task_definitions.each do |task_definition| + next if task_definition.target_grade > project.target_grade + + task = tasks[task_definition.id] + next unless outstanding?(task) + + due = due_date_for(task_definition, task, project) + next if due.nil? + + due = due.to_date + next if due < today || due > horizon + + notify(project, unit, task_definition) + end + end + + # Whether this student still owes work on this task. + # + # No row means nobody has touched it, which is not_started by any other name + # and is the state a reminder is most for. + def outstanding?(task) + return true if task.nil? + + OUTSTANDING_STATUSES.include?(task.status) + end + + # When this task is due for this student. + # + # Webcal already answers exactly this question, for exactly this pair of + # cases, and it is what the calendar feed shows the student. Writing it again + # here would mean two answers to "when is this due" that could disagree. + # + # With a Task row it is Task#local_due_date, which knows about extensions and + # about a unit's flexible dates. Without one it is still not simply the task + # definition's target date: on a unit with flexible dates the grade level + # override applies before any row exists, so a grade 2 student can be due days + # away from the unit's own date without ever having opened the task. + def due_date_for(task_definition, task, project) + Webcal.end_date_for_task_definition(task_definition, task, project) + end + + def notify(project, unit, task_definition) + student = project.student + link = "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + + # One reminder per student per task, ever. + # + # This job runs again tomorrow and the task is still due soon tomorrow, so + # without this the same student is reminded every morning until the deadline + # passes. The index on (user_id, event) is what makes asking cheap enough to + # do once per candidate task. + return if Notification.exists?( + user_id: student.id, + notification_type: TYPE, + event: EVENT, + link: link + ) + + NotificationService.notify( + user: student, + type: TYPE, + event: EVENT, + message: "#{task_definition.abbreviation} in #{unit.code} is due soon.", + link: link + ) + end +end diff --git a/app/views/notifications_mailer/task_due_soon.html.erb b/app/views/notifications_mailer/task_due_soon.html.erb new file mode 100644 index 0000000000..345e15906e --- /dev/null +++ b/app/views/notifications_mailer/task_due_soon.html.erb @@ -0,0 +1,22 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The deadline is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see when it is due and what is left to do. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at + your profile. +

diff --git a/app/views/notifications_mailer/task_due_soon.text.erb b/app/views/notifications_mailer/task_due_soon.text.erb new file mode 100644 index 0000000000..76c532794d --- /dev/null +++ b/app/views/notifications_mailer/task_due_soon.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The deadline is not included in this email. Open the task in <%= @doubtfire_product_name %> to see when it is due and what is left to do. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You are receiving this because your task notifications are turned on. +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/config/schedule.yml b/config/schedule.yml index 62fd893daf..911d69a116 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -24,6 +24,28 @@ poll_communication_set_schedules: cron: "every 5 minutes" class: "PollCommunicationSetSchedulesJob" +# Once a day, in the morning. +# +# A deadline moves once a day, so nothing is gained by sweeping for one every +# half hour, and every notification this job raises sends an email. Pinning it +# to a time also pins when the emails arrive: on a shorter interval a student +# gets theirs at whatever hour the task happened to cross into the window, +# which is 3am about as often as any other hour. +# +# Missing a run costs nothing. The job only asks whether a task is due within +# the next three days, so the following morning still catches everything the +# skipped run would have. +# +# 8am is 8am wherever the Sidekiq process thinks it is. There is no timezone in +# this file and no config.time_zone in the app, so the hour comes from TZ in the +# process environment. development/api.env sets TZ=Australia/Melbourne, which is +# what makes this a morning; a deployment that leaves TZ unset gets the image +# default of UTC and sends these in the evening. The entry above it, at 11:55pm, +# depends on the same thing. +send_due_soon_reminders: + cron: "every day at 8am" + class: "SendDueSoonRemindersJob" + # archive_old_units: # cron: "every 6 months" # class: "ArchiveOldUnitsJob" diff --git a/docs/notifications/events/task_due_soon.md b/docs/notifications/events/task_due_soon.md new file mode 100644 index 0000000000..ceea92f707 --- /dev/null +++ b/docs/notifications/events/task_due_soon.md @@ -0,0 +1,175 @@ +# Event: task_due_soon + +## Purpose + +Remind a student that a task they still owe is nearly due, so a deadline is not +the first they hear about it. + +## Trigger + +Nothing a person does. Every other event in this folder hangs off an action: +a comment is posted, a due date is edited, a status changes. A deadline getting +closer is nobody doing anything, so there is no model to hook and no callback to +add. The reminder has to be swept for. + +`SendDueSoonRemindersJob` does the sweep. `config/schedule.yml` runs it under the +name `send_due_soon_reminders`. + +## Schedule + +`every day at 8am`. + +Once a day, because a deadline only moves once a day and every notification this +job raises sends an email. Sweeping every thirty minutes would find the same +answer forty-eight times. + +At a fixed time, because that also fixes when the emails land. On a short +interval a student gets theirs at whatever hour their task happened to cross +into the window, which is 3am as often as any other hour. Pinning it to the +morning makes the reminder arrive on a day somebody can act on it. + +Missing a run costs nothing. The job asks whether a task is due within the next +three days, not whether it crossed a line since the last run, so tomorrow's run +still catches everything a skipped run would have. + +**Which 8am depends on the environment.** There is no timezone in the cron +expression, no `config.time_zone` in the app, and sidekiq-cron reads the process +clock, so the hour comes from `TZ`. `development/api.env` sets +`TZ=Australia/Melbourne`, which is what makes this a morning. A deployment that +leaves `TZ` unset gets the image default of UTC and sends these in the evening +local time. `aggregate_task_completion_stats`, at 11:55pm, already depends on the +same thing. + +**This has not been seen to fire.** There is no Sidekiq worker in the dev stack, +which is EN-F03, so the schedule entry is unverified beyond the assertion in +`test/sidekiq/scheduled_job_test.rb` that it loads and enqueues. + +## Window + +Three days, `SendDueSoonRemindersJob::WINDOW_DAYS`. + +Long enough to still do something about it over a weekend, short enough that the +reminder is about this task and not about the rest of the trimester. +`Project#top_tasks` calls seven days "soon" for its own purposes, and seven days +of warning on a weekly task is most of the tasks a student has, which is a list +rather than a reminder. + +## Recipient eligibility + +A project gets a reminder about a task definition only when: + +- the unit is active; +- the project is enrolled; +- the project has a target grade set; +- the task definition is assigned at that target grade; +- the task's status is one of `not_started`, `working_on_it`, `need_help`, + `fix_and_resubmit` or `redo`; and +- the student has task notifications enabled, which `NotificationService` + enforces rather than this job. + +Recipients come from projects and not from Task rows. OnTrack creates a Task row +the first time anyone touches the task, so the students who have not started +have no row, and they are the ones a reminder is for. + +**Nothing in this job may call `Project#task_for_task_definition`**, which +creates the row it cannot find, and nothing may call +`Project#task_definitions_and_status` either, because that calls it. The job +reads `project.tasks` once per project and looks each definition up in a hash. + +That is also why it is affordable. `task_definitions_and_status` asks for the +assigned definitions per project and then runs two more queries per definition, +so a five hundred student unit with twenty task definitions is upwards of twenty +thousand queries. Here the definitions are read once per unit and the tasks once +per project. + +### Statuses deliberately left out + +`discuss` and `demonstrate` both mean the student has submitted and is waiting on +a tutor. Telling them the task is due soon is wrong, and it is the kind of wrong +that teaches people to stop reading notifications, so `OUTSTANDING_STATUSES` +lists only the five that mean work is still owed. A project with no Task row at +all counts as outstanding. + +## Which deadline + +Per student, not per task definition, and answered by +`Webcal.end_date_for_task_definition(task_definition, task, project)`. + +That method already exists, already handles both cases, and is what the +student's calendar feed shows them, so writing the rule again here would mean +two answers to "when is this due" that could disagree. + +- With a Task row, `Task#local_due_date`, which knows about extensions and about + a unit's flexible dates. +- Without one, the grade level override when the unit has flexible dates, and + the task definition's own `target_date` otherwise. + +The second case is the one that is easy to get wrong. On a unit with flexible +dates the grade override applies before any Task row exists, so a grade 2 +student can be due days away from the unit's own date without ever having opened +the task. `Project#top_tasks` reads `target_date` directly and has this gap; +`Webcal` does not, which is why this follows `Webcal`. + +## Duplicates + +One reminder per student per task, ever. Before notifying, the job asks: + +```ruby +Notification.exists?(user_id:, notification_type: 'task', event: 'task_due_soon', link:) +``` + +The index `index_notifications_on_user_id_and_event` is what makes that cheap +enough to ask once per candidate task. Without the guard the job runs again +tomorrow, the task is still due soon tomorrow, and the same student is emailed +every morning until the deadline passes. + +Known consequence: a task whose deadline is later extended past the window and +then comes back into it does not produce a second reminder. That is deliberate. +The student asked for the extension, so they know about the task, and +`task_due_date_changed` covers a convenor moving it. + +## Notification fields + +- Type: `task` +- Event: `task_due_soon` +- Message: `" in is due soon."` +- Link: `/projects/:project_id/dashboard/:task_abbreviation` +- Preference: `receive_task_notifications` + +The date stays out of the message, the same as `task_due_date_changed`. The row +outlives the deadline it describes, so "due on the 14th" is wrong a week later +while "due soon" only stops being interesting. + +## Failure handling + +A failure on one project is logged, its id is collected, and the sweep carries on +through the rest of the cohort. At the end of `perform` the collected ids are +raised, which is what `NewTaskAvailableNotificationJob` does and for the same +reason: logging and returning normally would leave `perform` successful, Sidekiq +would schedule no retry, and a student whose task is due today is filtered out as +overdue tomorrow, so that reminder is gone for good. + +`retry: 1`. Re-running the whole sweep is only safe because of the duplicate +guard, which skips everyone already reminded. + +Sidekiq uniqueness rejects a second copy of the sweep, +`lock: :until_executed` on a fixed lock argument. + +## Templates + +- `app/views/notifications_mailer/task_due_soon.text.erb` +- `app/views/notifications_mailer/task_due_soon.html.erb` + +## Tests + +`test/sidekiq/send_due_soon_reminders_job_test.rb`, seventeen cases, covering +eligible students without Task rows, both edges of the window, an already passed +deadline, the duplicate guard, withdrawn students, inactive units, the +notification preference, target grade filtering, a student's own extended +deadline, a flexible unit's grade level deadline with no Task row, a task +waiting on a tutor, a failure being raised rather than swallowed, privacy-safe +content, and the event template. + +`test/sidekiq/scheduled_job_test.rb` covers the schedule entry loading and +enqueuing. It counts the entries in `config/schedule.yml`, so adding this one +changed the expected count from six to seven. diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index e21285fdf3..80b62eaa3e 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 6, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 7, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count @@ -15,6 +15,7 @@ def test_jobs_are_scheduled assert_equal 1, RefreshModerationFeedbackTimestampsJob.jobs.count assert_equal 1, AggregateTaskCompletionStatsJob.jobs.count assert_equal 1, PollCommunicationSetSchedulesJob.jobs.count + assert_equal 1, SendDueSoonRemindersJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end diff --git a/test/sidekiq/send_due_soon_reminders_job_test.rb b/test/sidekiq/send_due_soon_reminders_job_test.rb new file mode 100644 index 0000000000..09d84d0919 --- /dev/null +++ b/test/sidekiq/send_due_soon_reminders_job_test.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require 'test_helper' +# test_helper does not pull this in, and Object#stub comes from it. +require 'minitest/mock' + +class SendDueSoonRemindersJobTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + EVENT = 'task_due_soon' + WINDOW_DAYS = SendDueSoonRemindersJob::WINDOW_DAYS + + setup do + @unit = FactoryBot.create(:unit, task_count: 0) + + # This job sweeps every active unit there is, which is the whole point of + # it. rake db:populate seeds four more, each with a cohort and task + # definitions of its own, and their students would then land in every count + # in this file: the first run of these tests expected 11 notifications and + # got 27. Narrowing the world to the unit under test is what makes a plain + # Notification.count assertion mean what it says. + Unit.where.not(id: @unit.id).update_all(active: false) + + @task_def = FactoryBot.create( + :task_definition, + unit: @unit, + target_grade: 0, + start_date: Time.zone.now - 1.week, + target_date: Time.zone.now + 2.days + ) + + ActionMailer::Base.deliveries.clear + end + + # The students who most need a reminder are the ones who have not opened the + # task, and OnTrack has no Task row for them until somebody touches it. A + # sweep that read Task rows would miss exactly those people, and one that + # called task_for_task_definition would silently create a row per student per + # task every morning. + def test_reminds_every_eligible_student_without_creating_tasks + expected = @unit.active_projects.count + + assert_operator expected, :>=, 2 + assert_equal 0, @task_def.tasks.count + + assert_difference 'Notification.count', expected do + assert_no_difference 'Task.count' do + run_job + end + end + + assert_equal expected, ActionMailer::Base.deliveries.count + end + + def test_does_not_remind_about_a_deadline_further_out + @task_def.update!(target_date: Time.zone.now + (WINDOW_DAYS + 1).days) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_reminds_on_the_last_day_of_the_window + @task_def.update!(target_date: Time.zone.now + WINDOW_DAYS.days) + + # The far edge, where an off by one turns the window into WINDOW_DAYS - 1 + # without anything else looking wrong. + assert_difference 'Notification.count', @unit.active_projects.count do + run_job + end + end + + def test_reminds_about_something_due_today + @task_def.update!(target_date: Time.zone.now) + + assert_difference 'Notification.count', @unit.active_projects.count do + run_job + end + end + + def test_does_not_remind_once_the_deadline_has_passed + @task_def.update!(target_date: Time.zone.now - 1.day) + + # Overdue is a different message and a different ticket. A reminder saying + # a task is due soon when it is already late is worse than saying nothing. + assert_no_difference 'Notification.count' do + run_job + end + end + + # This is the whole reason the job carries a duplicate guard. It runs every + # morning and the task is still due soon tomorrow morning. + def test_does_not_remind_the_same_student_twice + run_job + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_does_not_remind_a_withdrawn_student + project = @unit.projects.find_by!(enrolled: false) + project.update!(target_grade: 3) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + end + + def test_does_not_remind_for_an_inactive_unit + @unit.update!(active: false) + + assert_no_difference 'Notification.count' do + run_job + end + end + + def test_respects_task_notification_preference + project = @unit.active_projects.first + project.student.update!(receive_task_notifications: false) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + end + + def test_does_not_remind_a_student_the_task_is_not_assigned_to + @task_def.update!(target_grade: 3) + below = @unit.active_projects.where('projects.target_grade < 3') + + assert_operator below.count, :>, 0 + + run_job + + below.each do |project| + assert_not Notification.exists?(user: project.student, event: EVENT) + end + end + + # An extension moves the deadline for one student and nobody else, so the + # date has to be read per student rather than off the task definition. + def test_uses_the_students_own_extended_deadline + project = @unit.active_projects.first + project.task_for_task_definition(@task_def).update!(extensions: 1) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + + # Everybody else is still on the original date and still gets one, so this + # is the extension being read and not the whole sweep falling over. + assert Notification.exists?(user: @unit.active_projects.second.student, event: EVENT) + end + + # :discuss and :demonstrate mean the student has submitted and is waiting on a + # tutor, so a reminder is both wrong and the kind of wrong that teaches people + # to ignore notifications. + def test_does_not_remind_about_a_task_that_is_waiting_on_a_tutor + project = @unit.active_projects.first + project.task_for_task_definition(@task_def).update!(task_status: TaskStatus.discuss) + + run_job + + assert_not Notification.exists?(user: project.student, event: EVENT) + end + + # A unit with flexible dates gives each target grade its own deadline, and + # that override applies before any Task row exists. Falling back to the task + # definition's own target date for a student with no row is wrong by however + # far apart those two dates are, and it is wrong in both directions: silence + # when something is due in two days, or a reminder a week early. + def test_uses_the_grade_deadline_when_the_unit_has_flexible_dates + @unit.update!(allow_flexible_dates: true) + @task_def.update!(target_date: Time.zone.now + 10.days) + + project = @unit.active_projects.find_by!(target_grade: 2) + @task_def.grade_due_dates.create!( + target_grade: 2, + start_date: Time.zone.now - 1.week, + target_due_date: Time.zone.now + 2.days + ) + + assert_equal 0, @task_def.tasks.count + + run_job + + assert Notification.exists?(user: project.student, event: EVENT) + + # Nobody else moved, so this is the override being read rather than the + # whole window sliding. + other = @unit.active_projects.where.not(target_grade: 2).first + + assert_not Notification.exists?(user: other.student, event: EVENT) + end + + # Logging a failure and carrying on leaves perform successful, Sidekiq + # schedules no retry, and a student whose task is due today is filtered out as + # overdue tomorrow. That reminder is then gone for good. + def test_a_failure_is_raised_so_sidekiq_retries + raising = ->(**_args) { raise 'notification failed' } + + NotificationService.stub(:notify, raising) do + assert_raises(RuntimeError) { run_job } + end + end + + def test_message_and_link_are_privacy_safe + project = @unit.active_projects.first + + run_job + + notification = Notification.find_by!(user: project.student, event: EVENT) + + assert_includes notification.message, @task_def.abbreviation + assert_includes notification.message, @unit.code + + # The date stays out, the same as task_due_date_changed. The row outlives + # the deadline it describes, so "due on the 14th" is wrong a week later + # while "due soon" only ever stops being interesting. + assert_not_includes notification.message, @task_def.target_date.to_date.to_s + assert_not_includes notification.message, @task_def.target_date.to_date.iso8601 + + assert_equal( + "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}", + notification.link + ) + + assert_valid_push_payload( + notification, + expected_link: "/projects/#{project.id}/dashboard/#{@task_def.abbreviation}" + ) + end + + def test_event_specific_template_is_used + run_job + + assert_includes delivered_body, 'The deadline is not included in this email' + end + + def test_the_schedule_entry_points_at_this_job + schedule = YAML.load_file(Rails.root.join('config/schedule.yml')) + + assert_equal( + 'SendDueSoonRemindersJob', + schedule.dig('send_due_soon_reminders', 'class') + ) + end + + private + + def run_job + SendDueSoonRemindersJob.new.perform + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + return mail.body.decoded unless mail.multipart? + + mail.parts.map { |part| part.body.decoded }.join("\n") + end +end From b11f3ccd8b3b5fccecd793145ed26b4fa6b4af80 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Mon, 17 Aug 2026 15:44:55 +1000 Subject: [PATCH 062/247] feat(notifications): collapse repeat push notifications with a tag payload_for now sets a tag, and the operating system does the collapsing. Two notifications sharing a tag means the second replaces the first on screen rather than stacking beneath it. The tag is the event plus the link. The link is the only handle the api has on the subject, and the event is what makes it a conversation rather than a topic: a run of comments on one task is one thing being said repeatedly, and a deadline change to the same task is something else that must not quietly replace it. notification_type is deliberately not used, because it is only the preference category, so task_due_date_changed, task_status_changed, new_task_available and task_due_soon are all `task` and would have shared a tag. The notification id is not in it either, which would collapse nothing at all. link is nullable, and with no link there is nothing to be about, so the tag falls back to the id and that notification collapses with nothing. One shared empty tag would hide every linkless notification behind the newest. renotify is false, so a replacement updates the banner without making a sound again. The case this exists for is a tutor posting five comments on one task in two minutes; the tag already collapses those, and renotify true would put the buzz back on every one of them. tag and renotify are both in NOTIFICATION_OPTION_NAMES in Angular's ngsw-worker.js, so both reach showNotification with no change on the web side. Title and body are untouched, and two of the new tests assert that, since the wording belongs to MN-D01 and MN-D05. Also adds require 'minitest/mock' to the test file. test_helper.rb does not pull it in, so Object#stub does not exist, and test_both_timeouts_are_passed_to_the_gem has been erroring with "undefined method 'stub' for module WebPush" since it was written. One line fixes that test too. Tests: 21 runs, 38 assertions, 0 failures, 0 errors. --- app/services/push_notification_service.rb | 48 +++++++ .../push_notification_service_test.rb | 119 ++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb index f83c33e1f2..6ea220ae9b 100644 --- a/app/services/push_notification_service.rb +++ b/app/services/push_notification_service.rb @@ -48,11 +48,31 @@ def self.deliver(notification) # has to write one. # # data.link is what MN-C03 reads to decide where to send the user on click. + # + # tag and renotify are both in the service worker's own list of forwarded + # option names (ngsw-worker.js, NOTIFICATION_OPTION_NAMES), so they reach + # showNotification without any change on the web side. def self.payload_for(notification) { notification: { title: Doubtfire::Application.config.institution[:product_name], body: notification.message.to_s.truncate(MAX_BODY_LENGTH), + tag: tag_for(notification), + # False, so a replacement updates the banner without making a sound or + # vibrating again. + # + # A burst is the case this exists for: a tutor working through one task + # posts five comments in two minutes. The tag already collapses those + # into one banner, and renotify: true would put the buzz back on every + # one of them, which is most of what made the burst worth collapsing. + # The user has already been interrupted once and the banner is already + # on their screen saying the newest thing. + # + # The cost is that a genuinely new message inside an ongoing + # conversation arrives silently while the old banner is still up. That + # is the right way round: the person has been told, and the alternative + # is being told five times. + renotify: false, data: { notification_id: notification.id, link: notification.link @@ -61,6 +81,34 @@ def self.payload_for(notification) }.to_json end + # What the operating system collapses on. + # + # Per conversation and not per notification. Two notifications sharing a tag + # means the second replaces the first on screen rather than stacking beneath + # it, so the tag has to name the thing being discussed and nothing that + # changes between messages about it. The notification id would be unique every + # time and collapse nothing at all. + # + # The event plus the link. The link is the only handle the api has on the + # subject, `/projects/9/dashboard/T1.1` being one student's copy of one task. + # The event is what makes it a conversation rather than a topic: a run of + # comments on a task is one thing being said repeatedly, and a deadline change + # to the same task is something else that must not quietly replace it. + # + # notification_type is deliberately not used here. It is the preference + # category, so task_due_date_changed, task_status_changed, new_task_available + # and task_due_soon are all `task`, and keying on it would let a status change + # silently take the place of a deadline alert about the same task. + # + # Without a link there is nothing to be about, so this falls back to the id + # and that notification collapses with nothing. That is the safe direction: + # sharing a tag between unrelated notifications would hide one behind another. + def self.tag_for(notification) + return "notification-#{notification.id}" if notification.link.blank? + + "#{notification.event}:#{notification.link}" + end + def self.deliver_to(subscription, payload) # Checked again here rather than trusted from the row. PushSubscription # validates this on write, but rows created before that validation existed diff --git a/test/services/push_notification_service_test.rb b/test/services/push_notification_service_test.rb index 6a95a61a5f..15bb1a3a41 100644 --- a/test/services/push_notification_service_test.rb +++ b/test/services/push_notification_service_test.rb @@ -1,4 +1,8 @@ require 'test_helper' +# test_helper does not pull this in, and Object#stub comes from it. Without it +# test_both_timeouts_are_passed_to_the_gem errors with "undefined method 'stub' +# for module WebPush", which it has done since it was written. +require 'minitest/mock' # MN-F02: the push channel actually sends. # @@ -54,6 +58,12 @@ def create_subscription(endpoint: ENDPOINT) FactoryBot.create(:push_subscription, user: @user, endpoint: endpoint) end + # Read out of the built payload rather than off tag_for, so these tests fail + # if the tag stops reaching the part that is actually sent. + def tag_for(notification) + JSON.parse(PushNotificationService.payload_for(notification))['notification']['tag'] + end + def test_nothing_is_sent_when_the_vapid_keys_are_missing create_subscription @@ -147,6 +157,115 @@ def test_the_payload_has_the_shape_angulars_service_worker_expects assert_not_nil body['title'] end + # MN-C06. The operating system does the collapsing, and it collapses on the + # tag. Two notifications carrying the same tag means the second replaces the + # first on screen instead of stacking under it. + def test_two_notifications_about_the_same_thing_share_a_tag + second = Notification.create!( + user: @user, + notification_type: @notification.notification_type, + event: @notification.event, + message: 'Andrew Cain commented on 1.1P in COS10001.', + link: @notification.link + ) + + assert_equal tag_for(@notification), tag_for(second) + end + + # The half that is easy to get wrong in the other direction. A tag shared by + # unrelated notifications does not tidy anything up, it hides one behind + # another and the user never sees it. + def test_notifications_about_different_things_do_not_share_a_tag + elsewhere = Notification.create!( + user: @user, + notification_type: @notification.notification_type, + event: @notification.event, + message: 'Andrew Cain commented on 2.1P in COS10001.', + link: '/projects/2/dashboard/2.1P' + ) + + assert_not_equal tag_for(@notification), tag_for(elsewhere) + end + + # The tag names the conversation, so it cannot contain anything that changes + # between messages in it. Using the notification id would be unique every time + # and would collapse nothing at all, which is the whole ticket undone. + def test_the_tag_does_not_change_between_notifications_in_a_burst + tags = 3.times.map do |index| + burst = Notification.create!( + user: @user, + notification_type: @notification.notification_type, + event: @notification.event, + message: "Andrew Cain commented on 1.1P in COS10001. (#{index})", + link: @notification.link + ) + + tag_for(burst) + end + + # Three different ids, one tag. If the id were part of it there would be + # three, so this is the assertion that pins the id out of the tag. + assert_equal 1, tags.uniq.length, tags.inspect + end + + # The one that decides between keying on the event and keying on + # notification_type. notification_type is only the preference category, so + # task_due_date_changed, task_status_changed, new_task_available and + # task_due_soon are all `task`. Keyed on that, a status change would silently + # take the place of a deadline alert about the same task, which is the failure + # this ticket exists to avoid rather than one to introduce. + def test_different_events_in_the_same_category_do_not_share_a_tag + deadline = Notification.create!( + user: @user, + notification_type: 'task', + event: 'task_due_date_changed', + message: 'The due date for 1.1P in COS10001 has changed.', + link: @notification.link + ) + due_soon = Notification.create!( + user: @user, + notification_type: 'task', + event: 'task_due_soon', + message: '1.1P in COS10001 is due soon.', + link: @notification.link + ) + + assert_equal deadline.notification_type, due_soon.notification_type + assert_not_equal tag_for(deadline), tag_for(due_soon) + end + + # link is nullable on the api, and there is nothing else to be about. Sharing + # one empty tag between every notification that happens to have no link would + # hide all but the newest of them. + def test_a_notification_with_no_link_collapses_with_nothing + first = Notification.create!( + user: @user, notification_type: 'general', event: 'system_announcement', message: 'One' + ) + second = Notification.create!( + user: @user, notification_type: 'general', event: 'system_announcement', message: 'Two' + ) + + assert_not_equal tag_for(first), tag_for(second) + assert tag_for(first).present? + end + + # Silent replacement is the point. renotify: true puts the sound and the + # vibration back on every message in the burst the tag exists to quieten. + def test_a_replacement_does_not_buzz_again + body = JSON.parse(PushNotificationService.payload_for(@notification))['notification'] + + assert_equal false, body['renotify'] + end + + # MN-D01 and MN-D05 own the wording. This ticket only adds the tag, so a + # change to either of those here is somebody else's work being overwritten. + def test_the_title_and_body_are_left_alone + body = JSON.parse(PushNotificationService.payload_for(@notification))['notification'] + + assert_equal 'Andrew Cain commented on 1.1P in COS10001.', body['body'] + assert_equal Doubtfire::Application.config.institution[:product_name], body['title'] + end + def test_a_long_message_is_trimmed_rather_than_rejected_by_the_push_service @notification.update!(message: 'a' * 500) From 97fdddb15ceca62ffd1c6c01036a7d2c6ef606b6 Mon Sep 17 00:00:00 2001 From: Ronit Khokhar Date: Mon, 17 Aug 2026 19:42:50 +1000 Subject: [PATCH 063/247] docs(notifications): add recipient amplification risk review --- .../reviews/recipient_amplification_risk.md | 1110 +++++++++++++++++ 1 file changed, 1110 insertions(+) create mode 100644 docs/notifications/reviews/recipient_amplification_risk.md diff --git a/docs/notifications/reviews/recipient_amplification_risk.md b/docs/notifications/reviews/recipient_amplification_risk.md new file mode 100644 index 0000000000..dd5c940807 --- /dev/null +++ b/docs/notifications/reviews/recipient_amplification_risk.md @@ -0,0 +1,1110 @@ +\# EN-S04 – Recipient and Email Amplification Risk Review + + + +\## Purpose + + + +This review looks at the recipient and email amplification risks for notification events EN-V01 to EN-V08. + + + +The main question for each event is: + + + +\- Who should actually receive the notification? + +\- Can one user action cause several internal updates? + +\- If it can, how many emails could that generate? + +\- Is there already a guard in place? + +\- If not, what kind of guard should be added when the event is implemented? + + + +This is a review task only. No production notification code was changed as part of EN-S04. + + + +The review was completed against the current `feature/notifications` branch of `doubtfire-api`. + + + +\--- + + + +\## Main amplification risks found + + + +While reviewing the notification paths, I found three places where one logical action can result in several internal operations. + + + +\### 1. Unit date changes + + + +`Unit#propogate\_date\_changes\_to\_tasks` runs when a unit start date changes. + + + +It loops through the unit's task definitions and calls: + + + +`td.propogate\_date\_changes date\_diff` + + + +`TaskDefinition#propogate\_date\_changes` then changes the task dates and saves the TaskDefinition. + + + +This means one unit date change can save many TaskDefinitions. + + + +If a due-date email was attached directly to a general TaskDefinition update callback, one unit-level change could accidentally generate emails for every affected task and every eligible student. + + + +If there are `T` task definitions and `S` eligible students, the worst-case fan-out could be approximately: + + + +`T × S emails` + + + +The current EN-V01 implementation avoids this by raising the event from the normal task-definition update API rather than from a generic model callback. + + + +\--- + + + +\### 2. Moving a group between tutorials + + + +`Group#switch\_to\_tutorial` processes every project in the group. + + + +For each project it temporarily calls: + + + +`remove\_member(proj, notify: false)` + + + +and later: + + + +`add\_member(proj, notify: false)` + + + +These membership changes are internal steps needed to move the group. They are not real group leave/join events. + + + +Without the `notify: false` guard, a group with `N` members could receive: + + + +`N removal emails + N addition emails` + + + +or: + + + +`2N false emails` + + + +from one tutorial move. + + + +The current implementation correctly suppresses these temporary notifications. + + + +\--- + + + +\### 3. Group task transitions + + + +`GroupSubmission#propagate\_transition` loops through the tasks belonging to the group submission. + + + +For the other eligible tasks it calls: + + + +`task.trigger\_transition(... group\_transition: true ...)` + + + +This means one group submission can cause several task transition calls internally. + + + +Any notification added to the task transition path needs to distinguish the original action from the propagated transitions. Otherwise one logical group submission could generate several notifications. + + + +The existing `group\_transition` flag gives us a way to make that distinction. + + + +\--- + + + +\# EN-V01 – Task due date changed + + + +\## Trigger + + + +The current implementation raises this event from: + + + +`app/api/task\_definitions\_api.rb` + + + +After the normal task-definition update, the API checks whether `due\_date` actually changed and queues: + + + +`TaskDueDateChangedNotificationJob` + + + +The notification is deliberately not attached to every TaskDefinition save. + + + +\## Recipient + + + +The intended recipients are eligible students affected by that task. + + + +The current job filters based on the active unit, enrolment and target grade. The existing task-notification preference is then handled through the notification system. + + + +\## Worst case + + + +For one directly changed task and `S` eligible students: + + + +`S emails` + + + +This is expected because the change affects the cohort. + + + +The more dangerous case would be a unit date change affecting `T` tasks, which could become: + + + +`T × S emails` + + + +if the notification was attached to every TaskDefinition save. + + + +\## Existing guard + + + +The current API-level trigger avoids that cascade. + + + +The job also checks that the queued due date is still current, which helps avoid stale notifications if the date changes again before the job runs. + + + +\## Recommendation + + + +Keep this event attached to the normal task-definition update workflow. + + + +Do not move it to a generic TaskDefinition lifecycle callback. + + + +If students need to be notified about a bulk unit schedule change in the future, a separate unit-level notification or digest would be safer than one email for every changed task. + + + +\--- + + + +\# EN-V02 – New task available + + + +\## Trigger + + + +The current implementation queues: + + + +`NewTaskAvailableNotificationJob` + + + +after a TaskDefinition is successfully created through the normal task-definition API. + + + +A generic `TaskDefinition after\_create` callback is not used. + + + +\## Recipient + + + +The intended recipients are students for whom the new task is actually available. + + + +The current job checks things such as: + + + +\- active unit; + +\- current enrolment; + +\- target-grade eligibility; + +\- effective task start date; and + +\- task notification preference. + + + +\## Worst case + + + +For one new task and `S` eligible students: + + + +`S emails` + + + +This is expected. + + + +There are other TaskDefinition creation paths, including CSV/import-related code. If a bulk operation created `T` tasks and each automatically triggered a cohort notification, the fan-out could become: + + + +`T × S emails` + + + +from one import. + + + +\## Existing guard + + + +The current implementation only queues EN-V02 from the normal API creation path. + + + +It also checks for an existing notification for the same student, event and task link before sending, which protects against the fan-out job being run again. + + + +\## Recommendation + + + +Keep the current API-level trigger. + + + +Do not replace it with a generic `after\_create` callback. + + + +If imports, rollovers or copying should generate this notification later, those workflows should be reviewed separately so that a bulk action does not unexpectedly email students once for every created task. + + + +\--- + + + +\# EN-V03 – Task due soon + + + +\## Trigger + + + +There is no model update that naturally happens when a deadline becomes close, so EN-V03 uses: + + + +`SendDueSoonRemindersJob` + + + +The job is scheduled through `config/schedule.yml`. + + + +\## Recipient + + + +The job considers students with outstanding eligible tasks whose actual due date falls within the reminder window. + + + +It uses the existing due-date calculation rather than simply reading the raw TaskDefinition date. + + + +\## Worst case + + + +A scheduled run can legitimately find many student/task combinations. + + + +If `S` students each have `T` eligible outstanding tasks inside the reminder window, the theoretical fan-out can approach: + + + +`S × T reminders` + + + +This is expected scheduled workload rather than amplification from a single convenor action. + + + +\## Existing guard + + + +Before sending, the job checks whether that student/task already has a `task\_due\_soon` notification. + + + +The intended behaviour is therefore: + + + +`one reminder per student per task` + + + +Running the job again should not send the same reminder again. + + + +\## Recommendation + + + +Keep the duplicate check. + + + +The schedule should not be made unnecessarily frequent because each newly matched student/task pair can result in an email. + + + +The schedule entry should also not be described as fully verified in development until the required Sidekiq worker is available. + + + +\--- + + + +\# EN-V04 – Tutorial changed + + + +\## Proposed trigger + + + +The relevant method is: + + + +`Project#enrol\_in` + + + +This method has different behaviours that need to be kept separate. + + + +If the project is already in the requested tutorial, there is no real change. + + + +If there is no existing matching enrolment, the method creates a new TutorialEnrolment. That is a first-time enrolment and should not be treated as a tutorial change. + + + +The actual move happens when an existing enrolment is updated to another `tutorial\_id`. + + + +\## Recipient + + + +The correct recipient is: + + + +`project.student` + + + +Only the student whose tutorial changed should receive the notification. + + + +The whole old tutorial or new tutorial should not be treated as the recipient list. + + + +\## Worst case + + + +A normal one-student move should generate: + + + +`1 email` + + + +A group tutorial move involving `N` students could legitimately result in: + + + +`N tutorial-change emails` + + + +if each student's tutorial really changes. + + + +\## Risk + + + +The main recipient risk would be notifying everyone in the old or new tutorial rather than only the students whose enrolments changed. + + + +There is also a risk of treating first-time enrolment as a tutorial move. + + + +\## Recommendation + + + +Only raise EN-V04 when an existing tutorial enrolment actually changes tutorial. + + + +Do not notify for: + + + +\- first-time enrolment; + +\- selecting the same tutorial again; or + +\- internal enrolment cleanup that does not represent a real move. + + + +The recipient should remain the affected project's student. + + + +Bulk/import callers should be considered separately before they are allowed to generate student emails. + + + +\--- + + + +\# EN-V05 – Group membership changed + + + +\## Trigger + + + +The current implementation uses: + + + +`Group#add\_member` + + + +and: + + + +`Group#remove\_member` + + + +\## Recipient + + + +The current scope is student-only. + + + +The recipient is the student whose membership changed. + + + +Other members of the group are not notified. + + + +\## Worst case + + + +A normal direct addition should generate: + + + +`1 email` + + + +A normal direct removal should generate: + + + +`1 email` + + + +The important amplification case is `Group#switch\_to\_tutorial`. + + + +Without a guard, a group with `N` members could receive: + + + +`2N false group membership emails` + + + +because every student is temporarily removed and added again. + + + +\## Existing guard + + + +The current tutorial-switch path calls both membership methods with: + + + +`notify: false` + + + +This correctly prevents the internal remove/add operations from becoming real notification events. + + + +The current implementation also avoids broadcasting the event to every member of the group. + + + +\## Stale member risk + + + +The notification uses the project involved in the current add/remove operation rather than walking old GroupMembership records. + + + +This reduces the risk of former or inactive members receiving a notification about a later membership change. + + + +\## Recommendation + + + +Keep the existing `notify: false` guard for internal operations. + + + +The event should continue to notify only the student whose membership changed unless the team explicitly decides that group-wide notifications are required. + + + +Bulk membership changes should also remain explicitly controlled rather than inheriting notification behaviour automatically. + + + +\--- + + + +\# EN-V06 – Student submitted for marking + + + +\## Proposed trigger + + + +This event overlaps with: + + + +`Task#trigger\_transition` + + + +because a student submission moves the task into a ready-for-marking/feedback state. + + + +EN-E02 already uses this transition area for task status notifications, so EN-V06 should not add another call blindly without checking the existing behaviour. + + + +\## Recipient + + + +The intended recipient is: + + + +`project.tutor\_for(task\_definition)` + + + +The implementation must handle the case where no tutor is returned. + + + +\## Amplification risk + + + +Group submissions are the important case. + + + +`GroupSubmission#propagate\_transition` can call `trigger\_transition` for the other tasks in the group with: + + + +`group\_transition: true` + + + +If EN-V06 simply sent an email every time the transition method ran, one group submission could create several tutor emails. + + + +For a group with `N` member tasks, one logical submission could potentially result in up to: + + + +`N notification attempts` + + + +instead of one. + + + +\## Recommendation + + + +Only the original submission should raise the tutor notification. + + + +Transitions where: + + + +`group\_transition: true` + + + +should not independently raise the same `task\_submitted` event. + + + +Another clean option would be to raise the notification once from the group-submission boundary rather than once from each member task. + + + +There is also a legitimate volume concern even after the amplification issue is fixed. A tutor with many students could receive many independent submission emails in a short period. + + + +That is not a duplicate-notification bug, but it may be worth discussing whether a future digest would provide a better experience. + + + +\--- + + + +\# EN-V07 – Portfolio submission received + + + +\## Proposed trigger + + + +The portfolio submission path writes: + + + +`project.portfolio\_submission\_date = Time.zone.now` + + + +in: + + + +`app/api/projects\_api.rb` + + + +The reviewed branch does not currently contain a `portfolio\_received` event. + + + +\## Existing portfolio emails + + + +The existing `PortfolioEvidenceMailer` contains: + + + +\- `portfolio\_ready` + +\- `portfolio\_failed` + + + +These describe what happened after portfolio generation. + + + +They are different from a confirmation that the student's submission itself was received. + + + +\## Recipient + + + +The intended recipient should be: + + + +`project.student` + + + +\## Worst case + + + +A normal accepted portfolio submission should generate: + + + +`1 confirmation email` + + + +The risk is repeated requests or retries generating multiple receipt emails for the same logical submission. + + + +\## Recommendation + + + +Only raise the receipt notification when a genuine portfolio submission is accepted. + + + +The implementation should prevent a retry or repeated request for the same submission from creating another receipt, while still allowing a later genuine resubmission to receive its own confirmation. + + + +The exact deduplication mechanism should be agreed with the lead before implementation. + + + +EN-V07 should also remain separate from the existing `portfolio\_ready` and `portfolio\_failed` emails because they represent different stages of the portfolio process. + + + +\--- + + + +\# EN-V08 – Discussion or check-in booked + + + +\## Scope finding + + + +This event cannot currently be implemented as written. + + + +The reviewed API does not contain a booking model, appointment model or calendar booking table that represents a future discussion booking. + + + +The existing discussion/check-in related models represent things that have already happened rather than a future appointment. + + + +For example, the existing discussed-comment path records a discussion that has already taken place. + + + +\## Recipient + + + +There is no reliable recipient or trigger to review until the event itself is redefined. + + + +\## Recommendation + + + +Do not force EN-V08 onto an unrelated model or invent a booking concept. + + + +The replacement event needs to be agreed with the lead first. + + + +One possible replacement mentioned in the ticket is a notification when a discussion prompt is raised for a student, but that should only be implemented if the team agrees that this is the intended replacement. + + + +If no replacement is agreed, closing or rescoping EN-V08 is the correct outcome. + + + +\--- + + + +\# Risk Summary + + + +| Event | Expected fan-out from one action | Main risk | Current/recommended guard | + +|---|---:|---|---| + +| EN-V01 – Due date changed | `S` | Unit date propagation could become `T × S` | Keep API-level trigger; avoid generic TaskDefinition callback | + +| EN-V02 – New task | `S` | Bulk creation/import could become `T × S` | Keep normal API trigger; handle bulk paths separately | + +| EN-V03 – Due soon | Up to `S × T` per scheduled sweep | Same reminder being sent every run | Existing duplicate check: one reminder per student/task | + +| EN-V04 – Tutorial changed | `1` normally, `N` for a real group move | Notifying whole tutorials or first-time enrolments | Notify only projects whose existing enrolment actually changed | + +| EN-V05 – Group changed | `1` normally | Tutorial switch could create `2N` false emails | Existing `notify: false` guard | + +| EN-V06 – Submitted for marking | `1` intended | Group transition could create up to `N` notification attempts | Suppress propagated `group\_transition` calls or notify once at group boundary | + +| EN-V07 – Portfolio received | `1` intended | Duplicate confirmation after retry/repeated request | Send once per genuine submission occurrence | + +| EN-V08 – Discussion booked | N/A | No booking concept exists | Rescope before implementation | + + + +\--- + + + +\# General recommendations + + + +From this review, the main rule I would follow for the remaining v2 notification work is: + + + +\*\*A notification should represent one meaningful user-facing event, not every internal model operation needed to complete that event.\*\* + + + +In particular: + + + +1\. Use the project/student directly affected by the event instead of building unnecessarily broad recipient lists. + + + +2\. Avoid generic lifecycle callbacks where the same model is also changed by imports, rollovers, propagation or maintenance operations. + + + +3\. Use existing context flags such as `group\_transition` when an internal update needs to be distinguished from the original action. + + + +4\. Keep bulk operations explicit. A CSV import or group-wide change should not start emailing large numbers of people simply because it happens to call the same model method as an individual action. + + + +5\. Use duplicate protection for jobs that may be retried or scheduled repeatedly. + + + +6\. Check current membership/enrolment state rather than using historical relationships when deciding recipients. + + + +7\. Where an event has no matching domain action, as with EN-V08, rescope it rather than forcing a notification onto the wrong hook. + + + +\--- + + + +\# Conclusion + + + +The review confirmed that the biggest email amplification risks are caused by internal cascades rather than by the email templates themselves. + + + +The three clearest examples are: + + + +\- a unit date change updating many TaskDefinitions; + +\- a group tutorial move temporarily removing and re-adding every member; and + +\- a group task submission propagating the same transition across several tasks. + + + +The current EN-V01, EN-V02, EN-V03 and EN-V05 work already contains useful safeguards against these problems. + + + +For the remaining events, the most important protections are to keep recipients narrow, distinguish real user actions from internal propagation, and prevent repeated execution from generating duplicate email. + + + +EN-V08 should remain unimplemented until the team agrees on a replacement event because the current API does not contain a discussion-booking concept. + + + +Overall, the safest pattern is: + + + +\*\*one logical event → the intended current recipient(s) → no extra email just because the action caused several internal records to change.\*\* + From 9a6b914f000ef39619d7dc054cf7f5f6501e1972 Mon Sep 17 00:00:00 2001 From: anaghwadhwa123 Date: Wed, 29 Jul 2026 16:05:50 +1000 Subject: [PATCH 064/247] fix(api): secure settings api against unauthenticated access --- app/api/settings_api.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb index 49968392ca..eaa261cb47 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -1,6 +1,12 @@ require 'grape' class SettingsApi < Grape::API + helpers AuthenticationHelpers + + before do + authenticated? + end + # # Returns the current auth method # From 814ceebe5ebf5a9a06d5b920c7ab9f3be4801c9c Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Wed, 19 Aug 2026 01:43:30 +1000 Subject: [PATCH 065/247] fix(ppi): quantise student progress percentages --- app/api/peer_progress_api.rb | 11 ++++++++++- docs/peer-progress-api.md | 2 +- test/api/peer_progress_api_test.rb | 14 +++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb index 204e2b89fa..f4ab1f185a 100644 --- a/app/api/peer_progress_api.rb +++ b/app/api/peer_progress_api.rb @@ -9,6 +9,7 @@ class PeerProgressApi < Grape::API NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.' CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.' MINIMUM_SAFE_COHORT_SIZE = 5 + PERCENTAGE_BUCKET_SIZE = 5.0 before do header 'Cache-Control', 'private, no-store' @@ -40,6 +41,12 @@ def released_for_project?(project:, task_definition:) start_date.present? && start_date <= Time.zone.now end + def quantised_percentage(value) + bucket_size = PeerProgressApi::PERCENTAGE_BUCKET_SIZE + + ((value.to_f / bucket_size).round * bucket_size).to_f + end + def safe_target_grade(project) target_grade = project.target_grade @@ -160,7 +167,9 @@ def peer_progress_result(project:, task_definition:) project: project, task_definition: task_definition, snapshot: snapshot, - submitted_percentage: snapshot.submitted_percentage.to_f + submitted_percentage: quantised_percentage( + snapshot.submitted_percentage + ) ) end end diff --git a/docs/peer-progress-api.md b/docs/peer-progress-api.md index a4d92422c6..77534fb044 100644 --- a/docs/peer-progress-api.md +++ b/docs/peer-progress-api.md @@ -52,7 +52,7 @@ count. "task_definition_id": 12, "unit_id": 5, "target_grade": 2, - "submitted_percentage": 62.5, + "submitted_percentage": 65.0, "is_suppressed": false, "is_stale": false, "is_feature_enabled": true, diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index 8f4747b57f..c8b2e477b8 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -106,7 +106,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal @task_definition.id, body['task_definition_id'] assert_equal @unit.id, body['unit_id'] assert_equal @project.target_grade, body['target_grade'] - assert_in_delta 62.5, body['submitted_percentage'], 0.001 + assert_equal 65.0, body['submitted_percentage'] assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] @@ -197,6 +197,18 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status end + test 'quantises the student percentage to five point buckets' do + create_snapshot( + submitted_percentage: 61, + cohort_size: 10 + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 60.0, last_response_body['submitted_percentage'] + end + test 'fails closed when the cohort configuration is below the privacy floor' do create_snapshot( submitted_percentage: 50, From f99c2c3e6eebf7db383b228eb4afca1dd26df09b Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Wed, 19 Aug 2026 02:14:28 +1000 Subject: [PATCH 066/247] fix(ppi): invalidate snapshots after grade changes --- app/api/peer_progress_api.rb | 36 +++++-- app/models/project.rb | 8 ++ ...add_target_grade_changed_at_to_projects.rb | 22 +++++ test/api/peer_progress_api_test.rb | 97 ++++++++++++++++--- 4 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb index f4ab1f185a..9f3408a040 100644 --- a/app/api/peer_progress_api.rb +++ b/app/api/peer_progress_api.rb @@ -41,6 +41,12 @@ def released_for_project?(project:, task_definition:) start_date.present? && start_date <= Time.zone.now end + def snapshot_predates_target_grade?(project, snapshot) + changed_at = project.target_grade_changed_at + + changed_at.present? && snapshot.calculated_at < changed_at + end + def quantised_percentage(value) bucket_size = PeerProgressApi::PERCENTAGE_BUCKET_SIZE @@ -127,33 +133,49 @@ def peer_progress_result(project:, task_definition:) target_grade: target_grade ) - if snapshot.nil? || snapshot.cohort_size.zero? || - snapshot.submitted_percentage.nil? + if snapshot.nil? || + snapshot_predates_target_grade?(project, snapshot) return peer_progress_payload( project: project, task_definition: task_definition, - snapshot: snapshot, unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE ) end - minimum_cohort_size = minimum_cohort_size! - + minimum_cohort_size = positive_integer_env!( + 'DF_PPI_MINIMUM_COHORT_SIZE' + ) stale_after_hours = positive_integer_env!( 'DF_PPI_STALE_AFTER_HOURS' ) + is_stale = snapshot.calculated_at < stale_after_hours.hours.ago + + # Treat an empty cohort exactly like every other cohort below the + # privacy threshold. This prevents the response from revealing + # whether a target-grade group is empty or merely small. if snapshot.cohort_size < minimum_cohort_size return peer_progress_payload( project: project, task_definition: task_definition, snapshot: snapshot, is_suppressed: true, + is_stale: is_stale, + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + if snapshot.submitted_percentage.nil? + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_stale: is_stale, unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE ) end - if snapshot.calculated_at < stale_after_hours.hours.ago + if is_stale return peer_progress_payload( project: project, task_definition: task_definition, @@ -170,7 +192,7 @@ def peer_progress_result(project:, task_definition:) submitted_percentage: quantised_percentage( snapshot.submitted_percentage ) - ) +) end end diff --git a/app/models/project.rb b/app/models/project.rb index 64dc33ed4e..24de545911 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -35,6 +35,10 @@ class Project < ApplicationRecord has_many :staff_notes, dependent: :destroy has_many :engagements, dependent: :destroy, inverse_of: :project + before_create :record_target_grade_change + before_update :record_target_grade_change, + if: :will_save_change_to_target_grade? + # Callbacks - methods called are private before_destroy :can_destroy? @@ -718,6 +722,10 @@ def escalation_attempts_remaining private + def record_target_grade_change + self.target_grade_changed_at = Time.current + end + def can_destroy? return true if tutorial_enrolments.count == 0 diff --git a/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb b/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb new file mode 100644 index 0000000000..c365432987 --- /dev/null +++ b/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddTargetGradeChangedAtToProjects < ActiveRecord::Migration[8.0] + def up + add_column :projects, :target_grade_changed_at, :datetime + + # Existing projects have no trustworthy record of when their current + # target grade was selected. Backfill to now so existing snapshots fail + # closed until the next successful aggregation run. + execute <<~SQL + UPDATE projects + SET target_grade_changed_at = UTC_TIMESTAMP() + WHERE target_grade_changed_at IS NULL + SQL + + change_column_null :projects, :target_grade_changed_at, false + end + + def down + remove_column :projects, :target_grade_changed_at + end +end \ No newline at end of file diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index c8b2e477b8..f5e8cf4a51 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -62,6 +62,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase @unit.tutorials.first.campus ) @project.update!(target_grade: 1) + @project.update!(target_grade_changed_at: 1.year.ago) @task_definition = create( :task_definition, @@ -107,7 +108,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal @unit.id, body['unit_id'] assert_equal @project.target_grade, body['target_grade'] assert_equal 65.0, body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert body['last_updated_at'].present? @@ -127,7 +128,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_equal 0.0, body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal '', body['unavailable_message'] end @@ -351,7 +352,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -390,7 +391,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_equal 40.0, body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] end test 'hides the percentage when an active unit snapshot is stale' do @@ -407,7 +408,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal true, body['is_stale'] assert body['last_updated_at'].present? assert body['unavailable_message'].present? @@ -423,7 +424,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal false, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -464,7 +465,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_nil body['target_grade'] assert_nil body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -490,7 +491,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_nil body['target_grade'] assert_nil body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -514,7 +515,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] - assert_equal false, body['is_suppressed'] + assert_equal true, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert body['last_updated_at'].present? @@ -633,6 +634,59 @@ class PeerProgressApiTest < ActiveSupport::TestCase end end + test 'does not serve a snapshot created before the target grade changed' do + travel_to Time.zone.parse('2026-08-10 12:00:00 UTC') do + create_snapshot( + target_grade: 2, + submitted_percentage: 60, + cohort_size: 5, + calculated_at: 1.hour.ago + ) + + @project.update!(target_grade: 2) + + request_as(@student) + + assert_equal 200, last_response.status + + body = last_response_body + assert_peer_progress_response_contract(body) + + assert_equal 2, body['target_grade'] + assert_nil body['submitted_percentage'] + assert_equal true, body['is_suppressed'] + assert_nil body['last_updated_at'] + assert body['unavailable_message'].present? + end + end + + test 'records when a project target grade changes' do + project = create(:project) + original_timestamp = project.target_grade_changed_at + + travel 1.minute + project.update!(target_grade: project.target_grade + 1) + + assert_operator( + project.reload.target_grade_changed_at, + :>, + original_timestamp + ) + end + + test 'does not change the grade timestamp for an unrelated update' do + project = create(:project) + original_timestamp = project.target_grade_changed_at + + travel 1.minute + project.update!(started: !project.started) + + assert_equal( + original_timestamp, + project.reload.target_grade_changed_at + ) + end + test 'fails closed when required PPI configuration is missing' do create_snapshot( submitted_percentage: 50, @@ -650,6 +704,26 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_private_no_store end + test 'serves a fresh snapshot calculated after the target grade changed' do + travel_to Time.zone.parse('2026-08-10 12:00:00 UTC') do + @project.update!(target_grade: 2) + + travel 1.minute + + create_snapshot( + target_grade: 2, + submitted_percentage: 61, + cohort_size: 5, + calculated_at: Time.zone.now + ) + + request_as(@student) + + assert_equal 200, last_response.status + assert_equal 60.0, last_response_body['submitted_percentage'] + end + end + private def endpoint(project: @project, task_definition: @task_definition) @@ -666,13 +740,14 @@ def request_as(user, path = endpoint) def create_snapshot( submitted_percentage:, cohort_size:, - calculated_at: Time.zone.now + calculated_at: Time.zone.now, + target_grade: @project.target_grade ) create( :peer_progress_snapshot, unit: @unit, task_definition: @task_definition, - target_grade: @project.target_grade, + target_grade: target_grade, submitted_percentage: submitted_percentage, cohort_size: cohort_size, calculated_at: calculated_at From a8c0b182045026ec80b70d47034739ee156693dd Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Wed, 19 Aug 2026 05:33:48 +1000 Subject: [PATCH 067/247] fix(db): sync peer progress schema and collation --- db/migrate/20260809153000_create_peer_progress_snapshots.rb | 4 +++- db/schema.rb | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/db/migrate/20260809153000_create_peer_progress_snapshots.rb b/db/migrate/20260809153000_create_peer_progress_snapshots.rb index b86502ee0c..748e4f95fb 100644 --- a/db/migrate/20260809153000_create_peer_progress_snapshots.rb +++ b/db/migrate/20260809153000_create_peer_progress_snapshots.rb @@ -1,6 +1,8 @@ class CreatePeerProgressSnapshots < ActiveRecord::Migration[8.0] def change - create_table :peer_progress_snapshots do |t| + create_table :peer_progress_snapshots, + options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ' \ + 'COLLATE=utf8mb4_general_ci' do |t| t.references :unit, null: false t.references :task_definition, null: false diff --git a/db/schema.rb b/db/schema.rb index b62c6a5552..69c01f6628 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_10_033824) do +ActiveRecord::Schema[8.0].define(version: 2026_08_18_160804) 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 @@ -443,7 +443,7 @@ t.index ["task_definition_id"], name: "index_overseer_steps_on_task_definition_id" end - create_table "peer_progress_snapshots", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| + create_table "peer_progress_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id", null: false t.bigint "task_definition_id", null: false t.integer "target_grade", null: false @@ -481,6 +481,7 @@ t.integer "spec_con_days", default: 0, null: false t.bigint "assessor_id" t.datetime "portfolio_submission_date" + t.datetime "target_grade_changed_at", null: false t.index ["assessor_id"], name: "index_projects_on_assessor_id" t.index ["campus_id"], name: "index_projects_on_campus_id" t.index ["enrolled"], name: "index_projects_on_enrolled" From 457ea76a3cc827fffd42173b30ef62f5302dd42b Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Wed, 19 Aug 2026 07:22:40 +1000 Subject: [PATCH 068/247] style(ppi): satisfy rubocop --- app/api/entities/unit_entity.rb | 8 ++++---- .../20260809153000_create_peer_progress_snapshots.rb | 4 ++-- ...60818160804_add_target_grade_changed_at_to_projects.rb | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb index f957e57272..d23ee49f60 100644 --- a/app/api/entities/unit_entity.rb +++ b/app/api/entities/unit_entity.rb @@ -56,10 +56,10 @@ def can_read_unit_config?(my_role) expose :allow_flexible_dates, unless: :summary_only expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only expose :peer_progress_enabled, - unless: :summary_only, - if: lambda { |_unit, options| - can_read_unit_config?(options[:my_role]) - } + unless: :summary_only, + if: lambda { |_unit, options| + can_read_unit_config?(options[:my_role]) + } expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only diff --git a/db/migrate/20260809153000_create_peer_progress_snapshots.rb b/db/migrate/20260809153000_create_peer_progress_snapshots.rb index 748e4f95fb..a96373436f 100644 --- a/db/migrate/20260809153000_create_peer_progress_snapshots.rb +++ b/db/migrate/20260809153000_create_peer_progress_snapshots.rb @@ -1,8 +1,8 @@ class CreatePeerProgressSnapshots < ActiveRecord::Migration[8.0] def change create_table :peer_progress_snapshots, - options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ' \ - 'COLLATE=utf8mb4_general_ci' do |t| + options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ' \ + 'COLLATE=utf8mb4_general_ci' do |t| t.references :unit, null: false t.references :task_definition, null: false diff --git a/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb b/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb index c365432987..4e167bce59 100644 --- a/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb +++ b/db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb @@ -19,4 +19,4 @@ def up def down remove_column :projects, :target_grade_changed_at end -end \ No newline at end of file +end From 62ee29823e9e136d1d69f65f697e7dfc3ad27b6f Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Wed, 19 Aug 2026 07:25:15 +1000 Subject: [PATCH 069/247] fix(ppi): finalise privacy state safeguards --- app/api/peer_progress_api.rb | 8 +++---- docs/peer-progress-api.md | 37 +++++++++++++++++++++++++++--- test/api/peer_progress_api_test.rb | 20 ++++++++-------- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb index 9f3408a040..f83cc450f9 100644 --- a/app/api/peer_progress_api.rb +++ b/app/api/peer_progress_api.rb @@ -134,7 +134,7 @@ def peer_progress_result(project:, task_definition:) ) if snapshot.nil? || - snapshot_predates_target_grade?(project, snapshot) + snapshot_predates_target_grade?(project, snapshot) return peer_progress_payload( project: project, task_definition: task_definition, @@ -142,9 +142,7 @@ def peer_progress_result(project:, task_definition:) ) end - minimum_cohort_size = positive_integer_env!( - 'DF_PPI_MINIMUM_COHORT_SIZE' - ) + minimum_cohort_size = minimum_cohort_size! stale_after_hours = positive_integer_env!( 'DF_PPI_STALE_AFTER_HOURS' ) @@ -192,7 +190,7 @@ def peer_progress_result(project:, task_definition:) submitted_percentage: quantised_percentage( snapshot.submitted_percentage ) -) + ) end end diff --git a/docs/peer-progress-api.md b/docs/peer-progress-api.md index 77534fb044..26955b7bfa 100644 --- a/docs/peer-progress-api.md +++ b/docs/peer-progress-api.md @@ -30,6 +30,8 @@ camelCase interface. A genuine zero is returned as `0.0`. It is not treated as missing data. +Student-facing percentages are quantised to the nearest five percentage points. The precise stored aggregate is not returned by this API. + `submitted_percentage` must be `null` for suppressed, stale, disabled and unavailable states. The response never includes raw cohort size or submitted count. @@ -40,7 +42,8 @@ count. | --- | --- | --- | --- | --- | --- | | Normal | Number | False | False | True | Timestamp | | Genuine zero | `0.0` | False | False | True | Timestamp | -| Small cohort | `null` | True | False | True | Timestamp | +| Empty or small cohort, fresh | `null` | True | False | True | Timestamp | +| Empty or small cohort, stale | `null` | True | True | True | Timestamp | | Stale snapshot | `null` | False | True | True | Timestamp | | No snapshot | `null` | False | False | True | `null` | | No valid target grade | `null` | False | False | True | `null` | @@ -106,7 +109,7 @@ count. } ``` -### No target grade or no snapshot +### No valid target grade ``` json { "task_definition_id": 12, @@ -121,6 +124,22 @@ count. } ``` + +### No snapshot for a valid target grade +``` json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": null, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": null, + "unavailable_message": "Peer progress is currently unavailable." +} +``` + ### Disabled ``` json { @@ -158,12 +177,22 @@ The response must not include names, usernames, student IDs, peer project IDs, marks, feedback, individual task statuses, submitted counts, or raw cohort sizes. The endpoint reads `cohort_size` only to apply suppression. +An empty cohort and any cohort below the configured privacy threshold return the same suppressed state. A suppressed snapshot can also be stale, so `is_suppressed` and `is_stale` may both be `true`. + + +## Target-grade change protection + +Each project records `target_grade_changed_at`. The API does not return a +snapshot calculated before the current target-grade selection. After a target +grade change, peer progress remains unavailable until a newer aggregation run +creates a snapshot for that grade. + ## Configuration - `DF_PPI_MINIMUM_COHORT_SIZE`: approved minimum cohort size. - `DF_PPI_STALE_AFTER_HOURS`: approved maximum snapshot age. -Both must be positive integers. No production defaults are included. An enabled unit +`DF_PPI_STALE_AFTER_HOURS` must be a positive integer. `DF_PPI_MINIMUM_COHORT_SIZE` must be an integer of at least `5`. No production defaults are included. An enabled unit with a valid snapshot fails closed with HTTP 503 when either value is missing or invalid. ## Feature enablement @@ -173,6 +202,8 @@ privacy thresholds and the endpoint have been reviewed. ## Status behaviour +Malformed integer path parameters may return HTTP 400 from Grape before the project or task lookup. + - `200`: authorised request, including normal, zero, suppressed, stale, disabled, or unavailable state. - `404`: wrong user, project, unit, task, target-grade applicability, inactive unit, or unreleased task. The same message is used to reduce object enumeration. - `419`: OnTrack authentication failed. diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index f5e8cf4a51..a9e0df2ba1 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -68,7 +68,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase :task_definition, unit: @unit, target_grade: 0, - start_date: 1.day.ago, + start_date: Time.zone.parse('2026-01-01 00:00:00 UTC'), outcome_count: 0 ) end @@ -108,7 +108,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal @unit.id, body['unit_id'] assert_equal @project.target_grade, body['target_grade'] assert_equal 65.0, body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert body['last_updated_at'].present? @@ -128,7 +128,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_equal 0.0, body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal '', body['unavailable_message'] end @@ -352,7 +352,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -391,7 +391,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_equal 40.0, body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] end test 'hides the percentage when an active unit snapshot is stale' do @@ -408,7 +408,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_equal true, body['is_stale'] assert body['last_updated_at'].present? assert body['unavailable_message'].present? @@ -424,7 +424,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_peer_progress_response_contract(body) assert_nil body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal false, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -465,7 +465,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_nil body['target_grade'] assert_nil body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -491,7 +491,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_nil body['target_grade'] assert_nil body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] assert_nil body['last_updated_at'] @@ -654,7 +654,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 2, body['target_grade'] assert_nil body['submitted_percentage'] - assert_equal true, body['is_suppressed'] + assert_equal false, body['is_suppressed'] assert_nil body['last_updated_at'] assert body['unavailable_message'].present? end From cc17f4eb22e1fcba5d0644f6feba0be5b38639d5 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Wed, 19 Aug 2026 07:37:12 +1000 Subject: [PATCH 070/247] fix(settings): separate public bootstrap from protected flags --- app/api/api_root.rb | 2 + app/api/settings_api.rb | 19 +---- app/api/settings_public_api.rb | 27 +++++++ test/api/settings_test.rb | 133 ++++++++++++++++++++++----------- 4 files changed, 119 insertions(+), 62 deletions(-) create mode 100644 app/api/settings_public_api.rb diff --git a/app/api/api_root.rb b/app/api/api_root.rb index 3dbc682297..d904532d29 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -66,6 +66,7 @@ class ApiRoot < Grape::API mount GroupSetsApi mount LearningOutcomesApi mount ProjectsApi + mount SettingsPublicApi mount SettingsApi mount StudentsApi mount Submission::PortfolioApi @@ -125,6 +126,7 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to GroupSetsApi AuthenticationHelpers.add_auth_to LearningOutcomesApi AuthenticationHelpers.add_auth_to ProjectsApi + AuthenticationHelpers.add_auth_to SettingsApi AuthenticationHelpers.add_auth_to StudentsApi AuthenticationHelpers.add_auth_to Submission::PortfolioApi AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb index eaa261cb47..52b66f3c97 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -7,16 +7,9 @@ class SettingsApi < Grape::API authenticated? end - # - # Returns the current auth method - # - desc 'Return configurable details for the Doubtfire front end' + desc 'Return authenticated feature configuration for the Doubtfire front end' get '/settings' do response = { - externalName: Doubtfire::Application.config.institution[:product_name], - hasLogo: Doubtfire::Application.config.institution[:has_logo], - logoUrl: Doubtfire::Application.config.institution[:logo_url], - logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url], overseerEnabled: Doubtfire::Application.config.overseer_enabled, tiiEnabled: TurnItIn.enabled?, d2lEnabled: D2lIntegration.enabled? @@ -24,14 +17,4 @@ class SettingsApi < Grape::API present response, with: Grape::Presenters::Presenter end - - desc 'Return privacy policy details' - get '/settings/privacy' do - response = { - privacy: Doubtfire::Application.config.institution[:privacy], - plagiarism: Doubtfire::Application.config.institution[:plagiarism] - } - - present response, with: Grape::Presenters::Presenter - end end diff --git a/app/api/settings_public_api.rb b/app/api/settings_public_api.rb new file mode 100644 index 0000000000..c3a5c34dac --- /dev/null +++ b/app/api/settings_public_api.rb @@ -0,0 +1,27 @@ +require 'grape' + +class SettingsPublicApi < Grape::API + # This endpoint is required before sign-in. + # Keep this response explicitly allowlisted. + desc 'Return public branding details for the Doubtfire front end' + get '/settings/public' do + response = { + externalName: Doubtfire::Application.config.institution[:product_name], + hasLogo: Doubtfire::Application.config.institution[:has_logo], + logoUrl: Doubtfire::Application.config.institution[:logo_url], + logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url] + } + + present response, with: Grape::Presenters::Presenter + end + + desc 'Return public privacy policy details' + get '/settings/privacy' do + response = { + privacy: Doubtfire::Application.config.institution[:privacy], + plagiarism: Doubtfire::Application.config.institution[:plagiarism] + } + + present response, with: Grape::Presenters::Presenter + end +end diff --git a/test/api/settings_test.rb b/test/api/settings_test.rb index 2a922d1c93..00b0298382 100644 --- a/test/api/settings_test.rb +++ b/test/api/settings_test.rb @@ -1,48 +1,93 @@ require 'test_helper' require 'json' -class SettingTest < ActiveSupport::TestCase - include Rack::Test::Methods - include TestHelpers::AuthHelper - include TestHelpers::JsonHelper - - def app - Rails.application - end - - # Get config details - def test_get_config_details - expected_product_name = Doubtfire::Application.config.institution[:product_name] - - # Perform the GET - get '/api/settings' - - # Set returned details - returned_mes = last_response_body['externalName'] - - # Check if the call succeeds - assert_equal 200, last_response.status - # Check returned details match as expected - assert_equal expected_product_name, returned_mes - end - - # Get privacy policy details - def test_get_privacy_policy_details - expected_privacy = Doubtfire::Application.config.institution[:privacy] - expected_plagiarism = Doubtfire::Application.config.institution[:plagiarism] - - # Perform the GET - get '/api/settings/privacy' - - # Set two returned details - returned_privacy = last_response_body['privacy'] - returned_plagiarism = last_response_body['plagiarism'] - - # Check if the call succeeds - assert_equal 200, last_response.status - - # Check returned details match as expected - assert_equal expected_privacy, returned_privacy - assert_equal expected_plagiarism, returned_plagiarism - end +class SettingsTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + def test_public_settings_are_available_without_authentication + clear_auth_header + + get '/api/settings/public' + + assert_equal 200, last_response.status + assert_equal( + Doubtfire::Application.config.institution[:product_name], + last_response_body['externalName'] + ) + assert_equal( + Doubtfire::Application.config.institution[:has_logo], + last_response_body['hasLogo'] + ) + assert_equal( + Doubtfire::Application.config.institution[:logo_url], + last_response_body['logoUrl'] + ) + assert_equal( + Doubtfire::Application.config.institution[:logo_link_url], + last_response_body['logoLinkUrl'] + ) + + assert_equal( + %w[externalName hasLogo logoLinkUrl logoUrl].sort, + last_response_body.keys.sort + ) + end + + def test_authenticated_settings_reject_unauthenticated_requests + clear_auth_header + + get '/api/settings' + + assert_equal 419, last_response.status + assert_equal( + 'No authentication details provided. Authentication is required to access this resource.', + last_response_body['error'] + ) + end + + def test_authenticated_settings_are_available_with_authentication + add_auth_header_for + + get '/api/settings' + + assert_equal 200, last_response.status + assert_equal( + Doubtfire::Application.config.overseer_enabled, + last_response_body['overseerEnabled'] + ) + assert_equal TurnItIn.enabled?, last_response_body['tiiEnabled'] + assert_equal D2lIntegration.enabled?, last_response_body['d2lEnabled'] + + assert_equal( + %w[d2lEnabled overseerEnabled tiiEnabled].sort, + last_response_body.keys.sort + ) + end + + def test_privacy_policy_is_available_without_authentication + clear_auth_header + + get '/api/settings/privacy' + + assert_equal 200, last_response.status + assert_equal( + Doubtfire::Application.config.institution[:privacy], + last_response_body['privacy'] + ) + assert_equal( + Doubtfire::Application.config.institution[:plagiarism], + last_response_body['plagiarism'] + ) + + assert_equal( + %w[plagiarism privacy].sort, + last_response_body.keys.sort + ) + end end From b7a6a29ea3d52710f968e9bf95e4eef1de8cbd5e Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Wed, 19 Aug 2026 13:02:28 +1000 Subject: [PATCH 071/247] docs(notifications): correct amplification risk analysis --- .../reviews/recipient_amplification_risk.md | 1059 ++++------------- 1 file changed, 235 insertions(+), 824 deletions(-) diff --git a/docs/notifications/reviews/recipient_amplification_risk.md b/docs/notifications/reviews/recipient_amplification_risk.md index dd5c940807..cb6ffa9f49 100644 --- a/docs/notifications/reviews/recipient_amplification_risk.md +++ b/docs/notifications/reviews/recipient_amplification_risk.md @@ -1,1110 +1,521 @@ -\# EN-S04 – Recipient and Email Amplification Risk Review - - - -\## Purpose - - - -This review looks at the recipient and email amplification risks for notification events EN-V01 to EN-V08. - - - -The main question for each event is: - - - -\- Who should actually receive the notification? - -\- Can one user action cause several internal updates? - -\- If it can, how many emails could that generate? - -\- Is there already a guard in place? - -\- If not, what kind of guard should be added when the event is implemented? - - - -This is a review task only. No production notification code was changed as part of EN-S04. - - - -The review was completed against the current `feature/notifications` branch of `doubtfire-api`. - - - -\--- - - - -\## Main amplification risks found - - - -While reviewing the notification paths, I found three places where one logical action can result in several internal operations. - - - -\### 1. Unit date changes - - - -`Unit#propogate\_date\_changes\_to\_tasks` runs when a unit start date changes. - - - -It loops through the unit's task definitions and calls: - - - -`td.propogate\_date\_changes date\_diff` - - - -`TaskDefinition#propogate\_date\_changes` then changes the task dates and saves the TaskDefinition. - - - -This means one unit date change can save many TaskDefinitions. - - - -If a due-date email was attached directly to a general TaskDefinition update callback, one unit-level change could accidentally generate emails for every affected task and every eligible student. - - - -If there are `T` task definitions and `S` eligible students, the worst-case fan-out could be approximately: - - - -`T × S emails` - - - -The current EN-V01 implementation avoids this by raising the event from the normal task-definition update API rather than from a generic model callback. - - - -\--- - - - -\### 2. Moving a group between tutorials - - - -`Group#switch\_to\_tutorial` processes every project in the group. - - - -For each project it temporarily calls: - - - -`remove\_member(proj, notify: false)` - - - -and later: - - - -`add\_member(proj, notify: false)` - - - -These membership changes are internal steps needed to move the group. They are not real group leave/join events. - - - -Without the `notify: false` guard, a group with `N` members could receive: - - - -`N removal emails + N addition emails` - - - -or: - - - -`2N false emails` - - - -from one tutorial move. - - - -The current implementation correctly suppresses these temporary notifications. - - - -\--- - - - -\### 3. Group task transitions - - - -`GroupSubmission#propagate\_transition` loops through the tasks belonging to the group submission. - - - -For the other eligible tasks it calls: - - - -`task.trigger\_transition(... group\_transition: true ...)` - - - -This means one group submission can cause several task transition calls internally. - - - -Any notification added to the task transition path needs to distinguish the original action from the propagated transitions. Otherwise one logical group submission could generate several notifications. - - - -The existing `group\_transition` flag gives us a way to make that distinction. - - - -\--- - - - -\# EN-V01 – Task due date changed - - - -\## Trigger - - - -The current implementation raises this event from: - - - -`app/api/task\_definitions\_api.rb` - - - -After the normal task-definition update, the API checks whether `due\_date` actually changed and queues: - - - -`TaskDueDateChangedNotificationJob` - - - -The notification is deliberately not attached to every TaskDefinition save. - - - -\## Recipient - - - -The intended recipients are eligible students affected by that task. - - - -The current job filters based on the active unit, enrolment and target grade. The existing task-notification preference is then handled through the notification system. - - - -\## Worst case - - - -For one directly changed task and `S` eligible students: - - - -`S emails` - - - -This is expected because the change affects the cohort. - - - -The more dangerous case would be a unit date change affecting `T` tasks, which could become: - - - -`T × S emails` - - - -if the notification was attached to every TaskDefinition save. - - - -\## Existing guard - - - -The current API-level trigger avoids that cascade. - - - -The job also checks that the queued due date is still current, which helps avoid stale notifications if the date changes again before the job runs. - - - -\## Recommendation - - - -Keep this event attached to the normal task-definition update workflow. - - - -Do not move it to a generic TaskDefinition lifecycle callback. - - - -If students need to be notified about a bulk unit schedule change in the future, a separate unit-level notification or digest would be safer than one email for every changed task. - - - -\--- - - - -\# EN-V02 – New task available - - - -\## Trigger - - - -The current implementation queues: - - - -`NewTaskAvailableNotificationJob` - - - -after a TaskDefinition is successfully created through the normal task-definition API. - - - -A generic `TaskDefinition after\_create` callback is not used. - - - -\## Recipient - - - -The intended recipients are students for whom the new task is actually available. - - - -The current job checks things such as: - - - -\- active unit; - -\- current enrolment; - -\- target-grade eligibility; - -\- effective task start date; and - -\- task notification preference. - - - -\## Worst case - - - -For one new task and `S` eligible students: - - - -`S emails` - - - -This is expected. - - - -There are other TaskDefinition creation paths, including CSV/import-related code. If a bulk operation created `T` tasks and each automatically triggered a cohort notification, the fan-out could become: - - - -`T × S emails` - - - -from one import. - - - -\## Existing guard - - - -The current implementation only queues EN-V02 from the normal API creation path. - - - -It also checks for an existing notification for the same student, event and task link before sending, which protects against the fan-out job being run again. - - - -\## Recommendation - - - -Keep the current API-level trigger. - - - -Do not replace it with a generic `after\_create` callback. - - - -If imports, rollovers or copying should generate this notification later, those workflows should be reviewed separately so that a bulk action does not unexpectedly email students once for every created task. - - - -\--- - - - -\# EN-V03 – Task due soon - - - -\## Trigger - - - -There is no model update that naturally happens when a deadline becomes close, so EN-V03 uses: - - - -`SendDueSoonRemindersJob` - - - -The job is scheduled through `config/schedule.yml`. - - - -\## Recipient - - - -The job considers students with outstanding eligible tasks whose actual due date falls within the reminder window. - - - -It uses the existing due-date calculation rather than simply reading the raw TaskDefinition date. - - - -\## Worst case - - - -A scheduled run can legitimately find many student/task combinations. - - - -If `S` students each have `T` eligible outstanding tasks inside the reminder window, the theoretical fan-out can approach: - - - -`S × T reminders` - - - -This is expected scheduled workload rather than amplification from a single convenor action. - - - -\## Existing guard - - - -Before sending, the job checks whether that student/task already has a `task\_due\_soon` notification. - - - -The intended behaviour is therefore: - - - -`one reminder per student per task` - - - -Running the job again should not send the same reminder again. - - - -\## Recommendation - - - -Keep the duplicate check. - - - -The schedule should not be made unnecessarily frequent because each newly matched student/task pair can result in an email. - - - -The schedule entry should also not be described as fully verified in development until the required Sidekiq worker is available. - - - -\--- - - - -\# EN-V04 – Tutorial changed - - - -\## Proposed trigger - - - -The relevant method is: - - - -`Project#enrol\_in` - - - -This method has different behaviours that need to be kept separate. - - - -If the project is already in the requested tutorial, there is no real change. - - - -If there is no existing matching enrolment, the method creates a new TutorialEnrolment. That is a first-time enrolment and should not be treated as a tutorial change. - - - -The actual move happens when an existing enrolment is updated to another `tutorial\_id`. - - - -\## Recipient - - - -The correct recipient is: - - - -`project.student` - - - -Only the student whose tutorial changed should receive the notification. - - - -The whole old tutorial or new tutorial should not be treated as the recipient list. - - - -\## Worst case - - - -A normal one-student move should generate: - - - -`1 email` - - - -A group tutorial move involving `N` students could legitimately result in: - - - -`N tutorial-change emails` - - - -if each student's tutorial really changes. - - - -\## Risk - - - -The main recipient risk would be notifying everyone in the old or new tutorial rather than only the students whose enrolments changed. - - - -There is also a risk of treating first-time enrolment as a tutorial move. - - - -\## Recommendation - - - -Only raise EN-V04 when an existing tutorial enrolment actually changes tutorial. - - - -Do not notify for: - - - -\- first-time enrolment; - -\- selecting the same tutorial again; or - -\- internal enrolment cleanup that does not represent a real move. - - - -The recipient should remain the affected project's student. - - - -Bulk/import callers should be considered separately before they are allowed to generate student emails. +# EN-S04 – Recipient and Email Amplification Risk Review +## Purpose +This review looks at the recipient and email amplification risks for notification events EN-V01 to EN-V08. -\--- +The main question for each event is: +- Who should actually receive the notification? +- Can one user action cause several internal updates? -\# EN-V05 – Group membership changed +- If it can, how many emails could that generate? +- Is there already a guard in place? +- If not, what kind of guard should be added when the event is implemented? -\## Trigger +This is a review task only. No production notification code was changed as part of EN-S04. +The review was completed against the current `feature/notifications` branch of `doubtfire-api`. +--- +## Main amplification risks found -The current implementation uses: +While reviewing the notification paths, I found three places where one logical action can result in several internal operations. +### 1. Unit date changes +`Unit#propogate_date_changes_to_tasks` runs when a unit start date changes. -`Group#add\_member` +It loops through the unit's task definitions and calls: +`td.propogate_date_changes date_diff` +`TaskDefinition#propogate_date_changes` then changes the task dates and saves the TaskDefinition. -and: +This means one unit date change can save many TaskDefinitions. +If a due-date email was attached directly to a general TaskDefinition update callback, one unit-level change could accidentally generate emails for every affected task and every eligible student. +If there are `T` task definitions and `S` eligible students, the worst-case fan-out could be approximately: -`Group#remove\_member` +`T × S emails` +The current EN-V01 implementation avoids this by raising the event from the normal task-definition update API rather than from a generic model callback. +--- +### 2. Moving a group between tutorials -\## Recipient +`Group#switch_to_tutorial` processes every project in the group. +For each project it temporarily calls: +`remove_member(proj, notify: false)` -The current scope is student-only. +and later: +`add_member(proj, notify: false)` +These membership changes are internal steps needed to move the group. They are not real group leave/join events. -The recipient is the student whose membership changed. +Without the `notify: false` guard, a group with `N` members could receive: +`N removal emails + N addition emails` +or: -Other members of the group are not notified. +`2N false emails` +from one tutorial move. +The current implementation correctly suppresses these temporary notifications. -\## Worst case +--- +### 3. Group task transitions +`Task#create_submission_and_trigger_state_change` loops across every task in a group submission. For the original task, `Task#trigger_transition` can also call `GroupSubmission#propagate_transition`, which calls `trigger_transition(... group_transition: true ...)` for every other task. +For `N` member tasks, a notification attached blindly to every transition invocation could therefore see up to: -A normal direct addition should generate: +`1 + (N - 1) + (N - 1) = 2N - 1 transition invocations` +The first term is the original task, the second is the propagation pass, and the third is the remaining calls from the outer group-task loop. Only `N` member tasks exist, and some repeated calls may be no-ops, but a new event still needs both an actual-status-change check and an original-action guard. +The existing `group_transition` flag can distinguish the original action from propagated or internal calls. Raising a group-submission notification once at the group boundary is safer still. -`1 email` +--- +# EN-V01 – Task due date changed +## Trigger -A normal direct removal should generate: +The current implementation raises this event from: +`app/api/task_definitions_api.rb` +After the normal task-definition update, the API checks whether `due_date` actually changed and queues: -`1 email` +`TaskDueDateChangedNotificationJob` +The notification is deliberately not attached to every TaskDefinition save. +## Recipient -The important amplification case is `Group#switch\_to\_tutorial`. +The intended recipients are eligible students affected by that task. +The current job filters based on the active unit, enrolment and target grade. The existing task-notification preference is then handled through the notification system. +## Worst case -Without a guard, a group with `N` members could receive: +For one directly changed task and `S` eligible students: +`S emails` +This is expected because the change affects the cohort. -`2N false group membership emails` +The more dangerous case would be a unit date change affecting `T` tasks, which could become: +`T × S emails` +if the notification was attached to every TaskDefinition save. -because every student is temporarily removed and added again. +## Existing guard +The current API-level trigger avoids that cascade. +The job also checks that the queued due date is still current, which helps avoid stale notifications if the date changes again before the job runs. -\## Existing guard +## Recommendation +Keep this event attached to the normal task-definition update workflow. +Do not move it to a generic TaskDefinition lifecycle callback. -The current tutorial-switch path calls both membership methods with: +If students need to be notified about a bulk unit schedule change in the future, a separate unit-level notification or digest would be safer than one email for every changed task. +--- +# EN-V02 – New task available +## Trigger -`notify: false` +The current implementation queues: +`NewTaskAvailableNotificationJob` +after a TaskDefinition is successfully created through the normal task-definition API. -This correctly prevents the internal remove/add operations from becoming real notification events. +A generic `TaskDefinition after_create` callback is not used. +## Recipient +The intended recipients are students for whom the new task is actually available. -The current implementation also avoids broadcasting the event to every member of the group. +The current job checks things such as: +- active unit; +- current enrolment; -\## Stale member risk +- target-grade eligibility; +- effective task start date; and +- task notification preference. -The notification uses the project involved in the current add/remove operation rather than walking old GroupMembership records. +## Worst case +For one new task and `S` eligible students: +`S emails` -This reduces the risk of former or inactive members receiving a notification about a later membership change. +This is expected. +There are other TaskDefinition creation paths, including CSV/import-related code. If a bulk operation created `T` tasks and each automatically triggered a cohort notification, the fan-out could become: +`T × S emails` -\## Recommendation +from one import. +## Existing guard +The current implementation only queues EN-V02 from the normal API creation path. -Keep the existing `notify: false` guard for internal operations. +It also checks for an existing notification for the same student, event and task link before sending, which protects against the fan-out job being run again. +## Recommendation +Keep the current API-level trigger. -The event should continue to notify only the student whose membership changed unless the team explicitly decides that group-wide notifications are required. +Do not replace it with a generic `after_create` callback. +If imports, rollovers or copying should generate this notification later, those workflows should be reviewed separately so that a bulk action does not unexpectedly email students once for every created task. +--- +# EN-V03 – Task due soon -Bulk membership changes should also remain explicitly controlled rather than inheriting notification behaviour automatically. +## Trigger +There is no model update that naturally happens when a deadline becomes close, so EN-V03 uses: +`SendDueSoonRemindersJob` -\--- +The job is scheduled through `config/schedule.yml`. +## Recipient +The job considers students with outstanding eligible tasks whose actual due date falls within the reminder window. -\# EN-V06 – Student submitted for marking +It uses the existing due-date calculation rather than simply reading the raw TaskDefinition date. +## Worst case +A scheduled run can legitimately find many student/task combinations. -\## Proposed trigger +If `S` students each have `T` eligible outstanding tasks inside the reminder window, the theoretical fan-out can approach: +`S × T reminders` +This is expected scheduled workload rather than amplification from a single convenor action. -This event overlaps with: +## Existing guard +Before sending, the job checks whether that student/task already has a `task_due_soon` notification. +The intended behaviour is therefore: -`Task#trigger\_transition` +`one reminder per student per task` +Running the job again should not send the same reminder again. +## Recommendation -because a student submission moves the task into a ready-for-marking/feedback state. +Keep the duplicate check. +The schedule should not be made unnecessarily frequent because each newly matched student/task pair can result in an email. +The schedule entry should also not be described as fully verified in development until the required Sidekiq worker is available. -EN-E02 already uses this transition area for task status notifications, so EN-V06 should not add another call blindly without checking the existing behaviour. +--- +# EN-V04 – Tutorial changed +## Proposed trigger +The relevant method is `Project#enrol_in`. -\## Recipient +A matching enrolment in the requested tutorial is a no-op. A newly created row, however, does not always mean a first-time enrolment. When a project has more than one tutorial enrolment and is moved to a tutorial without a stream, `Project#enrol_in` destroys the existing enrolments, sets the local enrolment to `nil`, and creates a replacement row. +For that reason, EN-V04 must compare the project's effective tutorial IDs before and after the operation. It must not use `tutorial_enrolment.nil?` by itself to decide that this is a first-time enrolment. +## Recipient -The intended recipient is: +The correct recipient is `project.student`. +Only the student whose effective tutorial membership changed should receive the notification. The whole old tutorial or new tutorial must not be used as the recipient list. +## Worst case -`project.tutor\_for(task\_definition)` +A normal one-student move should generate `1 email`. +A real group tutorial move involving `N` students can legitimately generate `N tutorial-change emails`, one for each student whose effective tutorial membership changed. +## Risk -The implementation must handle the case where no tutor is returned. +The main recipient risk is notifying everyone in the old or new tutorial rather than only the affected students. +There is also a state-detection risk: the multiple-enrolment consolidation path creates a replacement row even though it represents a genuine tutorial move. +## Recommendation -\## Amplification risk +Capture the project's tutorial IDs before and after `Project#enrol_in`, and raise EN-V04 only when there was at least one prior tutorial enrolment and the effective tutorial set genuinely changed. +Do not notify for: +- a genuine first enrolment; +- selecting the same tutorial again; or +- internal cleanup that leaves the effective tutorial membership unchanged. -Group submissions are the important case. +Keep the recipient as the affected project's student. Review bulk and import callers separately before allowing them to generate student emails. +--- +# EN-V05 – Group membership changed -`GroupSubmission#propagate\_transition` can call `trigger\_transition` for the other tasks in the group with: +## Trigger +The current implementation uses: +`Group#add_member` -`group\_transition: true` +and: +`Group#remove_member` +## Recipient -If EN-V06 simply sent an email every time the transition method ran, one group submission could create several tutor emails. +The current scope is student-only. +The recipient is the student whose membership changed. +Other members of the group are not notified. -For a group with `N` member tasks, one logical submission could potentially result in up to: +## Worst case +A normal direct addition should generate: +`1 email` -`N notification attempts` +A normal direct removal should generate: +`1 email` +The important amplification case is `Group#switch_to_tutorial`. -instead of one. +Without a guard, a group with `N` members could receive: +`2N false group membership emails` +because every student is temporarily removed and added again. -\## Recommendation +## Existing guard +The current tutorial-switch path calls both membership methods with: +`notify: false` -Only the original submission should raise the tutor notification. +This correctly prevents the internal remove/add operations from becoming real notification events. +The current implementation also avoids broadcasting the event to every member of the group. +## Stale member risk -Transitions where: +The notification uses the project involved in the current add/remove operation rather than walking old GroupMembership records. +This reduces the risk of former or inactive members receiving a notification about a later membership change. +## Recommendation -`group\_transition: true` +Keep the existing `notify: false` guard for internal operations. +The event should continue to notify only the student whose membership changed unless the team explicitly decides that group-wide notifications are required. +Bulk membership changes should also remain explicitly controlled rather than inheriting notification behaviour automatically. -should not independently raise the same `task\_submitted` event. +--- +# EN-V06 – Student submitted for marking +## Proposed trigger +This event overlaps with `Task#trigger_transition` because a student submission moves a task into a ready-for-marking or feedback state. -Another clean option would be to raise the notification once from the group-submission boundary rather than once from each member task. +EN-E02 already uses this transition area for task status notifications, so EN-V06 must not add another notification call blindly without checking the existing behaviour. +## Recipient +The intended recipient is the authorised tutor returned by `project.tutor_for(task_definition)`. -There is also a legitimate volume concern even after the amplification issue is fixed. A tutor with many students could receive many independent submission emails in a short period. +The implementation must safely handle a missing recipient. Before implementation, the team should also record whether a cross-tutorial group has one responsible tutor or should notify each distinct authorised tutor. It must not silently assume that every member project resolves to the same tutor. +## Amplification risk +Group submissions are the important case. -That is not a duplicate-notification bug, but it may be worth discussing whether a future digest would provide a better experience. +`Task#create_submission_and_trigger_state_change` loops across all `N` member tasks. The original task's `Task#trigger_transition` can also call `GroupSubmission#propagate_transition`, which calls `trigger_transition(... group_transition: true ...)` for the other `N - 1` tasks. +A notification attached to every transition invocation could therefore see up to: +`2N - 1 transition invocations` -\--- +This consists of the original call, `N - 1` propagation calls, and `N - 1` remaining calls from the outer group-task loop. Only `N` member tasks exist and some repeated calls may be no-ops, but an event sent without an actual-status-change guard could still duplicate. +## Recommendation +The cleanest option is to raise EN-V06 once from the group-submission boundary rather than once from every member task. -\# EN-V07 – Portfolio submission received +If the notification remains inside `Task#trigger_transition`, require all of the following: +- the task genuinely changed into the submitted or ready-for-feedback state; +- the call represents the original action, with `group_transition: false`; and +- duplicate protection prevents the same logical submission from notifying the same authorised tutor twice. +Record the lead-approved tutor rule for cross-tutorial groups. A tutor receiving many independent submissions is a separate volume concern and may support a later digest, but it is not the same as duplicate amplification. -\## Proposed trigger +--- +# EN-V07 – Portfolio submission received +## Proposed trigger The portfolio submission path writes: - - -`project.portfolio\_submission\_date = Time.zone.now` - - +`project.portfolio_submission_date = Time.zone.now` in: +`app/api/projects_api.rb` +The reviewed branch does not currently contain a `portfolio_received` event. -`app/api/projects\_api.rb` - - - -The reviewed branch does not currently contain a `portfolio\_received` event. - - - -\## Existing portfolio emails - - +## Existing portfolio emails The existing `PortfolioEvidenceMailer` contains: +- `portfolio_ready` - -\- `portfolio\_ready` - -\- `portfolio\_failed` - - +- `portfolio_failed` These describe what happened after portfolio generation. - - They are different from a confirmation that the student's submission itself was received. - - -\## Recipient - - +## Recipient The intended recipient should be: - - `project.student` - - -\## Worst case - - +## Worst case A normal accepted portfolio submission should generate: - - `1 confirmation email` - - The risk is repeated requests or retries generating multiple receipt emails for the same logical submission. - - -\## Recommendation - - +## Recommendation Only raise the receipt notification when a genuine portfolio submission is accepted. - - The implementation should prevent a retry or repeated request for the same submission from creating another receipt, while still allowing a later genuine resubmission to receive its own confirmation. - - The exact deduplication mechanism should be agreed with the lead before implementation. +EN-V07 should also remain separate from the existing `portfolio_ready` and `portfolio_failed` emails because they represent different stages of the portfolio process. +--- +# EN-V08 – Discussion or check-in booked -EN-V07 should also remain separate from the existing `portfolio\_ready` and `portfolio\_failed` emails because they represent different stages of the portfolio process. - - - -\--- - - - -\# EN-V08 – Discussion or check-in booked - - - -\## Scope finding - - +## Scope finding This event cannot currently be implemented as written. - - The reviewed API does not contain a booking model, appointment model or calendar booking table that represents a future discussion booking. - - The existing discussion/check-in related models represent things that have already happened rather than a future appointment. - - For example, the existing discussed-comment path records a discussion that has already taken place. - - -\## Recipient - - +## Recipient There is no reliable recipient or trigger to review until the event itself is redefined. - - -\## Recommendation - - +## Recommendation Do not force EN-V08 onto an unrelated model or invent a booking concept. - - The replacement event needs to be agreed with the lead first. - - One possible replacement mentioned in the ticket is a notification when a discussion prompt is raised for a student, but that should only be implemented if the team agrees that this is the intended replacement. - - If no replacement is agreed, closing or rescoping EN-V08 is the correct outcome. - - -\--- - - - -\# Risk Summary - - +--- +# Risk Summary | Event | Expected fan-out from one action | Main risk | Current/recommended guard | - |---|---:|---|---| - | EN-V01 – Due date changed | `S` | Unit date propagation could become `T × S` | Keep API-level trigger; avoid generic TaskDefinition callback | - | EN-V02 – New task | `S` | Bulk creation/import could become `T × S` | Keep normal API trigger; handle bulk paths separately | - | EN-V03 – Due soon | Up to `S × T` per scheduled sweep | Same reminder being sent every run | Existing duplicate check: one reminder per student/task | - -| EN-V04 – Tutorial changed | `1` normally, `N` for a real group move | Notifying whole tutorials or first-time enrolments | Notify only projects whose existing enrolment actually changed | - +| EN-V04 – Tutorial changed | `1` normally, `N` for a real group move | Whole-tutorial recipients or misclassifying the replacement-row path | Compare effective tutorial IDs before and after; notify only genuinely changed students | | EN-V05 – Group changed | `1` normally | Tutorial switch could create `2N` false emails | Existing `notify: false` guard | - -| EN-V06 – Submitted for marking | `1` intended | Group transition could create up to `N` notification attempts | Suppress propagated `group\_transition` calls or notify once at group boundary | - +| EN-V06 – Submitted for marking | `1` intended; up to `2N - 1` transition invocations | A per-call hook can duplicate one group submission | Notify once at the group boundary, or require a real change and `group_transition: false`; record the tutor rule | | EN-V07 – Portfolio received | `1` intended | Duplicate confirmation after retry/repeated request | Send once per genuine submission occurrence | - | EN-V08 – Discussion booked | N/A | No booking concept exists | Rescope before implementation | - - -\--- - - - -\# General recommendations - - +--- +# General recommendations From this review, the main rule I would follow for the remaining v2 notification work is: - - -\*\*A notification should represent one meaningful user-facing event, not every internal model operation needed to complete that event.\*\* - - +**A notification should represent one meaningful user-facing event, not every internal model operation needed to complete that event.** In particular: +1. Use the project/student directly affected by the event instead of building unnecessarily broad recipient lists. +2. Avoid generic lifecycle callbacks where the same model is also changed by imports, rollovers, propagation or maintenance operations. -1\. Use the project/student directly affected by the event instead of building unnecessarily broad recipient lists. - - - -2\. Avoid generic lifecycle callbacks where the same model is also changed by imports, rollovers, propagation or maintenance operations. - +3. Use existing context flags such as `group_transition` when an internal update needs to be distinguished from the original action. +4. Keep bulk operations explicit. A CSV import or group-wide change should not start emailing large numbers of people simply because it happens to call the same model method as an individual action. -3\. Use existing context flags such as `group\_transition` when an internal update needs to be distinguished from the original action. +5. Use duplicate protection for jobs that may be retried or scheduled repeatedly. +6. Check current membership/enrolment state rather than using historical relationships when deciding recipients. +7. Where an event has no matching domain action, as with EN-V08, rescope it rather than forcing a notification onto the wrong hook. -4\. Keep bulk operations explicit. A CSV import or group-wide change should not start emailing large numbers of people simply because it happens to call the same model method as an individual action. - - - -5\. Use duplicate protection for jobs that may be retried or scheduled repeatedly. - - - -6\. Check current membership/enrolment state rather than using historical relationships when deciding recipients. - - - -7\. Where an event has no matching domain action, as with EN-V08, rescope it rather than forcing a notification onto the wrong hook. - - - -\--- - - - -\# Conclusion - - +--- +# Conclusion The review confirmed that the biggest email amplification risks are caused by internal cascades rather than by the email templates themselves. - - The three clearest examples are: +- a unit date change updating many TaskDefinitions; +- a group tutorial move temporarily removing and re-adding every member; and -\- a unit date change updating many TaskDefinitions; - -\- a group tutorial move temporarily removing and re-adding every member; and - -\- a group task submission propagating the same transition across several tasks. - - +- a group task submission propagating the same transition across several tasks. The current EN-V01, EN-V02, EN-V03 and EN-V05 work already contains useful safeguards against these problems. - - For the remaining events, the most important protections are to keep recipients narrow, distinguish real user actions from internal propagation, and prevent repeated execution from generating duplicate email. - - EN-V08 should remain unimplemented until the team agrees on a replacement event because the current API does not contain a discussion-booking concept. - - Overall, the safest pattern is: - - -\*\*one logical event → the intended current recipient(s) → no extra email just because the action caused several internal records to change.\*\* - +**one logical event → the intended current recipient(s) → no extra email just because the action caused several internal records to change.** From 7c7b16af85f303b19da741d56ce10ff979881f1e Mon Sep 17 00:00:00 2001 From: Gaurav Myana Date: Wed, 19 Aug 2026 16:01:23 +0530 Subject: [PATCH 072/247] feat: add rake task to seed PPI sample dashboard test data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `rake db:ppi_sample_data`, creating a deterministic minimum test data set for testing the Peer Progress Indicator dashboard: 2 units, 2 classes of 16 students per unit (4 students per target grade — Pass/Credit/Distinction/High Distinction), and 7 tasks per unit with completion rates scaled by target grade so percentages are clearly distinguishable across grade bands. Verified against the local dev environment: both units created successfully with 32 students and 7 tasks each, and completion percentages visibly differ by grade band in the Students tab. --- lib/tasks/ppi_sample_data.rake | 135 +++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 lib/tasks/ppi_sample_data.rake diff --git a/lib/tasks/ppi_sample_data.rake b/lib/tasks/ppi_sample_data.rake new file mode 100644 index 0000000000..3390fb10dd --- /dev/null +++ b/lib/tasks/ppi_sample_data.rake @@ -0,0 +1,135 @@ +require_all 'lib/helpers' + +namespace :db do + desc 'Create a small, deterministic sample dataset for testing the Peer Progress Indicator dashboard' + task ppi_sample_data: [:skip_prod, :environment] do + Rails.logger.level = :info + + # ---- constants ----------------------------------------------------- + NUM_UNITS = 2 + CLASSES_PER_UNIT = 2 + STUDENTS_PER_GRADE = 4 + GRADE_LABELS = { 0 => 'Pass', 1 => 'Credit', 2 => 'Distinction', 3 => 'HighDistinction' }.freeze + GRADES = GRADE_LABELS.keys.freeze # [0, 1, 2, 3] + NUM_TASKS = 7 # within the requested 5-10 range + WEEKDAYS = %w[Monday Tuesday Wednesday Thursday Friday].freeze + + # ---- helpers --------------------------------------------------------- + + # Finds or creates a user with a fixed, deterministic username - safe to re-run. + def ppi_find_or_create_user(username, first_name, last_name, role_id) + existing = User.find_by(username: username) + return existing if existing + + profile = { + first_name: first_name, + last_name: last_name, + nickname: username, + role_id: role_id, + email: "#{username}@doubtfire.com", + username: username + } + unless AuthenticationHelpers.aaf_auth? + profile[:password] = 'password' + profile[:password_confirmation] = 'password' + end + User.create!(profile) + end + + campus = Campus.first || Campus.create!(name: 'Online', mode: 'timetable', abbreviation: 'C', active: true) + convenor = ppi_find_or_create_user('ppi_convenor', 'Peer', 'Convenor', Role.convenor_id) + + (1..NUM_UNITS).each do |unit_num| + code = "PPI100#{unit_num}" + unit = Unit.find_by(code: code) || Unit.create!( + code: code, + name: "PPI Sample Unit #{unit_num}", + description: 'Deterministic sample data for testing the Peer Progress Indicator dashboard. Not a real unit.', + start_date: Time.zone.now - 6.weeks, + end_date: Time.zone.now + 7.weeks + ) + + unit.employ_staff(convenor, Role.convenor) + + # All tasks are assigned regardless of a student's target grade (target_grade: 0 = Pass), + # so every student in the unit has the same task list - needed to compare % completion + # meaningfully across target-grade bands. + task_defs = (1..NUM_TASKS).map do |t| + unit.task_definitions.find_by(abbreviation: "T#{t}") || TaskDefinition.create!( + unit_id: unit.id, + name: "Task #{t}", + abbreviation: "T#{t}", + description: "Sample task #{t} for PPI dashboard testing.", + weighting: BigDecimal('1'), + target_grade: 0, + start_date: unit.start_date, + target_date: unit.start_date + t.weeks, + upload_requirements: [{ key: 'file0', name: 'Document', type: 'document' }] + ) + end + + (1..CLASSES_PER_UNIT).each do |class_num| + tutor_username = "ppi_tutor_u#{unit_num}c#{class_num}" + tutor = ppi_find_or_create_user(tutor_username, "Tutor#{unit_num}#{class_num}", 'PPI', Role.tutor_id) + unit.employ_staff(tutor, Role.tutor) + + tutorial_abbrev = "PPI-U#{unit_num}-C#{class_num}" + tutorial = unit.tutorials.find_by(abbreviation: tutorial_abbrev) || unit.add_tutorial( + WEEKDAYS[class_num - 1], + '10:00', + "EN1-0#{class_num}", + tutor, + campus, + STUDENTS_PER_GRADE * GRADES.length, + tutorial_abbrev + ) + + student_index = 0 + + GRADES.each do |target_grade| + STUDENTS_PER_GRADE.times do |i| + student_index += 1 + username = "ppi_u#{unit_num}c#{class_num}s#{student_index.to_s.rjust(2, '0')}" + student = ppi_find_or_create_user(username, "Student#{student_index}", GRADE_LABELS[target_grade], Role.student_id) + + project = unit.enrol_student(student, campus) + project.update!(target_grade: target_grade) + project.enrol_in(tutorial) + + # Vary completion so percentages differ meaningfully both between tasks + # and between target-grade bands: + # - higher target grade -> higher base completion rate + # - later tasks -> lower completion rate (fewer students have reached them) + # - a small per-student jitter spreads the 4 students within a grade band + task_defs.each_with_index do |td, td_idx| + task = project.task_for_task_definition(td) + next unless task.task_status_id == TaskStatus.not_started.id # skip on re-run + + base_completion = (target_grade + 1) / GRADES.length.to_f # 0.25, 0.5, 0.75, 1.0 + task_decay = 1.0 - (td_idx.to_f / task_defs.length) * 0.4 + student_jitter = (i - (STUDENTS_PER_GRADE - 1) / 2.0) * 0.05 + completion_chance = [[base_completion * task_decay + student_jitter, 0.05].max, 0.98].min + + seed = (student_index * 13) + (td_idx * 7) + (unit_num * 31) + (class_num * 17) + roll = (seed % 100) / 100.0 + + if roll < completion_chance + complete_date = [unit.start_date + (td_idx + 1).weeks + rand(0..3).days, Time.zone.now].min + DatabasePopulator.assess_task(project, task, tutor, TaskStatus.complete, complete_date) + elsif roll < completion_chance + 0.15 + DatabasePopulator.assess_task(project, task, tutor, TaskStatus.working_on_it, Time.zone.now) + end + # otherwise left as not_started (the Task.create! default) + end + + project.update_task_stats + end + end + end + + puts "-> #{unit.code}: #{unit.tutorials.count} classes, #{unit.projects.count} students, #{task_defs.count} tasks" + end + + puts 'PPI sample dashboard data ready.' + end +end From a03e17bc225e1609c0ec448ad6e60d449d5b72b3 Mon Sep 17 00:00:00 2001 From: Gaurav Myana Date: Wed, 19 Aug 2026 16:13:40 +0530 Subject: [PATCH 073/247] docs: publish PPI backend data-source and field-ownership map Adds a permanent technical reference documenting where the Peer Progress Indicator's response fields actually come from in the backend, so PPI-B01, PPI-F01, PPI-S01 and future contributors don't have to rediscover it. Builds on the earlier PPI API discovery task (preserved here as a companion doc), which found the reusable aggregation infrastructure existed but wasn't reachable by students. This document goes further: it reviews the real, unmerged implementation on ppi/student-progress-endpoint (PPI-B01) and maps all 9 response fields to their exact source file/method, availability status, any transformation applied, and the ticket that owns each one. Includes a Mermaid data-flow diagram traced from the actual code (auth -> authorised project -> task validation -> server-side target-grade lookup -> nightly cohort aggregation -> small-cohort suppression -> percentage quantisation -> safe response -> frontend adapter -> existing widget), safe example responses for four business states, and a recorded list of concrete gaps found while reviewing the branch (unset config env vars, no unit has PPI enabled yet, a frontend/backend target-grade parameter mismatch, two frontend branches that independently renamed the same model, and a backfill migration that will blank every existing snapshot on first deploy). Does not implement the endpoint, frontend adapter, or any of the other PPI-* work explicitly out of scope for this ticket. --- docs/peer-progress/data-source-map.md | 238 ++++++++++++++++++ .../task-completion-data-discovery.md | 62 +++++ 2 files changed, 300 insertions(+) create mode 100644 docs/peer-progress/data-source-map.md create mode 100644 docs/peer-progress/task-completion-data-discovery.md diff --git a/docs/peer-progress/data-source-map.md b/docs/peer-progress/data-source-map.md new file mode 100644 index 0000000000..9a8f44e041 --- /dev/null +++ b/docs/peer-progress/data-source-map.md @@ -0,0 +1,238 @@ +# Peer Progress Indicator — Backend Data-Source Map + +**Ticket:** PPI-D02 — Publish the peer-progress backend data-source and field-ownership map +**Status:** Documentation only. No production code is implemented or modified by this ticket. +**Builds on:** [PPI API discovery](./task-completion-data-discovery.md) — the earlier starter task that +located existing task-completion data (`Task`, `TaskStatus`, `Project#task_stats`, +`Unit#student_task_completion_stats`) and found it was not reachable by students. This document goes +one level deeper: it maps the *current, real backend implementation* (found on an unmerged branch) +against the agreed PPI response contract, field by field, and records what is still open. + +## Important finding before anything else + +At the time of writing, `feature/peer-progress-indicator` (the shared objective branch) has **no PPI +backend code merged into it at all**. A complete, working implementation exists, but it lives on a +separate, unmerged branch: **`ppi/student-progress-endpoint`** (`origin/ppi/student-progress-endpoint` +in `doubtfire-api`, owned by **PPI-B01**). Everything in the tables below that is marked "available" is +available *on that branch*, not on `feature/peer-progress-indicator`. Until PPI-B01 is reviewed and +merged, the shared branch has none of this. + +This document does not implement, modify, or take a position on merging that branch — it only records +what it contains so the rest of the team can build against it without re-discovering it. + +--- + +## 1. Backend data-source table + +| File | Class / method | Branch | Role | +|---|---|---|---| +| `app/api/peer_progress_api.rb` | `PeerProgressApi` (Grape API), `get '/projects/:id/task_def_id/:task_definition_id/peer_progress'` | `ppi/student-progress-endpoint` (unmerged) | Student-facing endpoint. Authorises the request, looks up the stored snapshot, applies suppression/staleness rules, returns the allowlisted response. | +| `app/models/peer_progress_snapshot.rb` | `PeerProgressSnapshot` | `ppi/student-progress-endpoint` (unmerged) | One row per `(unit, task_definition, target_grade)`. Stores `cohort_size`, `submitted_percentage`, `calculated_at`. Validates target grade is enabled for the unit and covers the task. | +| `app/services/peer_progress_aggregation_service.rb` | `PeerProgressAggregationService.call(unit:, calculated_at:)` | `ppi/student-progress-endpoint` (unmerged) | Batch job logic. For each grade value in the unit, selects the eligible cohort and counts submissions per task, then upserts `PeerProgressSnapshot` rows. | +| `app/sidekiq/aggregate_peer_progress_job.rb` | `AggregatePeerProgressJob#perform(unit_id = nil)` | `ppi/student-progress-endpoint` (unmerged) | Scheduled entry point. Iterates `Unit.active_units` and calls the aggregation service per unit. Scheduled via `config/schedule.yml` — `"every day at 11:45pm"`. | +| `db/migrate/20260809153000_create_peer_progress_snapshots.rb` | — | `ppi/student-progress-endpoint` (unmerged) | Creates `peer_progress_snapshots` table. Comment in the migration explicitly flags `cohort_size` as "Internal only. Never expose this raw value through the student API." | +| `db/migrate/20260810033824_add_peer_progress_enabled_to_units.rb` | — | `ppi/student-progress-endpoint` (unmerged) | Adds `units.peer_progress_enabled` boolean, `default: false, null: false`. | +| `app/models/unit.rb:259` | `Unit#active_projects` | `feature/peer-progress-indicator` (pre-existing) | Reused as the base scope for cohort selection (`unit.active_projects.where(target_grade: …)`). | +| `app/models/unit.rb:337` | `Unit#grade_value?` | `feature/peer-progress-indicator` (pre-existing) | Reused to validate a project's `target_grade` is actually a value the unit has enabled, both when aggregating and when deriving the safe target grade for a request. | +| `app/models/unit.rb:2607` | `Unit.active_units` | `feature/peer-progress-indicator` (pre-existing) | Reused so the nightly job skips inactive units. | +| `app/models/project.rb:124` | `Project.for_user(user, include_inactive)` | `feature/peer-progress-indicator` (pre-existing) | Reused to authorise that the requested project actually belongs to the authenticated student. | +| `app/models/task.rb` | `Task#file_uploaded_at` | `feature/peer-progress-indicator` (pre-existing column) | The signal used to decide whether a task counts as "submitted" for aggregation — see note below, this is **not** the same signal the original discovery task found. | +| `app/models/project.rb` | `Project#target_grade_changed_at`, `#record_target_grade_change` (`before_create`/`before_update` callback) | `ppi/student-progress-endpoint` (unmerged) | New column + callback. Records when a student's target grade last changed, so a stale snapshot calculated *before* a grade change is never shown as if it applied to the new grade. Backfill migration sets it to "now" for all existing projects — see Gaps §5. | +| `db/migrate/20260818160804_add_target_grade_changed_at_to_projects.rb` | — | `ppi/student-progress-endpoint` (unmerged) | Adds `projects.target_grade_changed_at`, backfilled to the migration run time for existing rows, then `NOT NULL`. | +| `app/api/units_api.rb`, `app/api/entities/unit_entity.rb` | `PUT /units/:id` accepts `peer_progress_enabled`; `UnitEntity` exposes it gated by `can_read_unit_config?` | `ppi/student-progress-endpoint` (unmerged) | Convenors can now toggle PPI on/off for a unit through the normal unit-update endpoint — this wasn't there when this document was first drafted (it required a manual DB flip). Still staff-only visibility, matching the "students never see raw config" pattern. | + +### Divergence from the original discovery task + +The [earlier discovery](./task-completion-data-discovery.md) found `Unit#student_task_completion_stats` +and `Project#task_stats` as existing, reusable aggregation infrastructure, built on `TaskStatus.complete`. +**PPI-B01 does not reuse either of them.** It introduces a parallel, PPI-specific path instead: + +- Completion signal: `Task.where(...).where.not(file_uploaded_at: nil)` (a file has been uploaded), not + `task_status_id == TaskStatus.complete.id`. Note this changed mid-development from an earlier + `submission_date`-based check to `file_uploaded_at` — if you're comparing against an older read of + this branch, that's the difference. +- Storage: a new `PeerProgressSnapshot` table, calculated nightly, not the ad-hoc per-request + `Unit#student_task_completion_stats` calculation. + +This looks like a deliberate design choice (a stored nightly snapshot makes the suppression/staleness +checks in the student-facing endpoint cheap and simple), not an oversight. It's recorded here so nobody +assumes the two paths are the same thing, and so **PPI-T01** (calculation rules) has an accurate +starting point if "submitted" vs "complete" needs revisiting. + +--- + +## 2. PPI field-ownership table + +Response contract as implemented in `PeerProgressApi#peer_progress_payload` +(`ppi/student-progress-endpoint`). All 9 fields are already implemented on that branch — **none are +conceptually missing from the design**, but none are merged into `feature/peer-progress-indicator` +either, so treat the whole endpoint as unavailable until that branch is reviewed and merged. + +| Field | Purpose | Current backend source | Available / Calculated / Missing | Transformation | Owning ticket | +|---|---|---|---|---|---| +| `task_definition_id` | Task context | Request param, validated via `unit.task_definitions.find_by(id:)` | Available | Passthrough of the validated ID | PPI-B01 | +| `unit_id` | Unit context | `project.unit_id` | Available | Passthrough | PPI-B01 | +| `target_grade` | Server-side target-grade lookup | `Project#target_grade`, validated through `Unit#grade_value?` inside `safe_target_grade` | Available (validated, not a raw column read) | Returns `nil` if the project has no target grade or it isn't enabled for the unit. **Never accepts a client-supplied value** — the route only takes `:id` and `:task_definition_id`. | PPI-B01 | +| `submitted_percentage` | Anonymous submitted percentage | `PeerProgressSnapshot#submitted_percentage`, computed nightly by `PeerProgressAggregationService#percentage` from `file_uploaded_at` presence counts | Calculated (batch, not live) | Stored rounded to 2 dp; **quantised to the nearest 5 percentage points** at request time (`quantised_percentage`, `PeerProgressApi`) before being returned — the precise stored value is never sent to the client, an extra anonymity margin on top of cohort suppression. Forced to `nil` (never `0` used as a sentinel) whenever suppressed, stale, disabled, unavailable, or the snapshot predates the student's last target-grade change. A genuine `0.0` result is preserved and distinguished from "no data." | PPI-B01 (endpoint) / PPI-T01 (whether submission-based is the right definition, and whether 5-point buckets are the agreed granularity) | +| `is_suppressed` | Small-cohort suppression | Computed per-request: `snapshot.cohort_size < minimum_cohort_size!` (env-configured, but hard-floored at `MINIMUM_SAFE_COHORT_SIZE = 5` regardless of config) | Calculated | `cohort_size` itself is read internally but **never included** in the response. An empty cohort (0 students) is deliberately treated identically to "below threshold" — the response can't distinguish "nobody's in this grade band" from "too few to show," by design. **Can now be `true` at the same time as `is_stale`** — suppression and staleness are no longer mutually exclusive branches. | PPI-S01 (approve the threshold) / PPI-B01 (implementation) | +| `is_stale` | Data freshness | Computed per-request: `snapshot.calculated_at < ENV['DF_PPI_STALE_AFTER_HOURS'].hours.ago` | Calculated | Computed once and threaded through every branch, so it can appear alongside `is_suppressed: true` in the same response — see above. | PPI-T01 (approve the freshness window) / PPI-B01 (implementation) | +| `is_feature_enabled` | Whether PPI is on for this unit | `units.peer_progress_enabled` column, `default: false`; now settable via `PUT /units/:id` | Available | None | Unit-level config, convenor-controlled. **Not enabled on any unit in the local dev database**, including the `PPI1001`/`PPI1002` sample units created for dashboard testing. See Gaps below. | +| `last_updated_at` | Snapshot freshness display | `snapshot.calculated_at.utc.iso8601` | Available when a snapshot exists, else `nil` | ISO 8601 UTC string | PPI-B01 / PPI-F01 (display formatting) | +| `unavailable_message` | Safe unavailable message | Hardcoded Ruby constants in `PeerProgressApi` (`UNAVAILABLE_MESSAGE`, etc.) | Available, but **placeholder wording** | None | PPI-D01 — user-facing wording is explicitly out of scope for PPI-B01; the current strings are implementation placeholders, not approved copy. | + +### Fields the response must never include (confirmed by code review) + +`peer_progress_payload` is an allowlist — it only ever builds the 9 fields above. Confirmed absent: +peer names, usernames, student IDs, peer project IDs, marks, feedback, individual task statuses, raw +cohort records, and raw `cohort_size` / submitted counts. The migration comment on `cohort_size` +explicitly flags it as internal-only. This satisfies acceptance criterion 6, based on reading the code +as it stands on `ppi/student-progress-endpoint` — **this is not a substitute for the independent +PPI-S01 review**, which should verify this against the actually-merged code, not this branch snapshot. + +--- + +## 3. Proposed / actual data-flow diagram + +```mermaid +flowchart TD + A["Authenticated student user
GET /api/projects/:id/task_def_id/:task_definition_id/peer_progress"] --> B["PeerProgressApi
authenticated? + role == student"] + B -->|"not a student / project not found"| X1["404 Not Found
(same message for all cases - avoids object enumeration)"] + B -->|ok| C["Project.for_user current_user
= authorised project/unit"] + C --> D["Task validation:
unit.task_definitions.find_by id
+ effective_task local_start_date released? (honours extensions)"] + D -->|"not found / not released"| X1 + D -->|ok| E["safe_target_grade project
= server-side target-grade lookup
(Project#target_grade validated via Unit#grade_value?)"] + E -->|"nil / not applicable"| F1a["200 OK, unavailable
target_grade: null
= no valid target grade"] + E -->|valid| F["PeerProgressSnapshot lookup
by unit_id + task_definition_id + target_grade"] + + subgraph nightly ["Nightly batch - AggregatePeerProgressJob (11:45pm, one job per unit)"] + G["Unit.active_units"] --> H["PeerProgressAggregationService.call"] + H --> I["Unit#active_projects.where target_grade: ...
= eligible cohort selection"] + I --> J["Task.where project in cohort,
file_uploaded_at not null
= aggregate calculation"] + J --> K[("PeerProgressSnapshot row
cohort_size, submitted_percentage, calculated_at")] + end + + K -.snapshot read at request time.-> F + F -->|"no snapshot yet"| F1b["200 OK, unavailable
target_grade: present
= no snapshot for a valid target grade"] + F -->|found| R{"snapshot.calculated_at older than
project.target_grade_changed_at ?"} + R -->|yes| F1b + R -->|no| L{"cohort_size below hard floor of 5,
or below DF_PPI_MINIMUM_COHORT_SIZE ?"} + L -->|yes| M1["200 OK
is_suppressed: true
(is_stale may ALSO be true)
= small-cohort suppression"] + L -->|no| N{"calculated_at older than
DF_PPI_STALE_AFTER_HOURS ?"} + N -->|yes| M2["200 OK
is_stale: true, percentage: null"] + N -->|no| M3["quantised_percentage
round to nearest 5 points"] + M3 --> M4["200 OK
submitted_percentage, last_updated_at
= safe API response"] + + F1a --> O + F1b --> O + M1 --> O + M2 --> O + M4 --> O["PeerProgressIndicatorService.getIndicator
frontend adapter (PPI-F01)
currently returns MOCK data only"] + O --> P["resolvePeerProgressState
PPI-F03 - UI state mapping"] + P --> Q["PpiWidgetComponent (f-ppi-widget)
existing PPI component
rendered inside task-description-card"] +``` + +--- + +## 4. Safe example responses + +Reproduced from `ppi/student-progress-endpoint`'s own `docs/peer-progress-api.md` (PPI-B01), which +already documents these in more state variations than required here. Shown in the backend's snake_case; +the frontend model (`PeerProgressIndicator`, on `feature/PPI-F03-ppi-widget-states`) maps these 1:1 to +camelCase (`submittedPercentage`, `isSuppressed`, etc.) — field names match, only casing differs. + +### Normal aggregate result + +Note `submitted_percentage` is quantised to the nearest 5 — the raw stored aggregate (e.g. 62.5) is +never returned; this example shows the quantised value the client actually receives. + +```json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": 65.0, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": "2026-08-10T03:15:00Z", + "unavailable_message": "" +} +``` + +### Small-cohort-suppressed result + +```json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": null, + "is_suppressed": true, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": "2026-08-10T03:15:00Z", + "unavailable_message": "Peer progress is currently unavailable." +} +``` + +### Unavailable result — no valid target grade + +```json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": null, + "submitted_percentage": null, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": null, + "unavailable_message": "Peer progress is currently unavailable." +} +``` + +### Unavailable result — valid target grade, no usable snapshot yet + +This is also what a student sees for one full day after a `peer_progress_enabled` unit's target-grade +backfill migration runs (Gaps §5, #8), and after a student changes their own target grade — the snapshot +that existed for their old grade is deliberately not shown for the new one. + +```json +{ + "task_definition_id": 12, + "unit_id": 5, + "target_grade": 2, + "submitted_percentage": null, + "is_suppressed": false, + "is_stale": false, + "is_feature_enabled": true, + "last_updated_at": null, + "unavailable_message": "Peer progress is currently unavailable." +} +``` + +--- + +## 5. Confirmed gaps and unresolved decisions + +| # | Gap / decision | Detail | Owner | +|---|---|---|---| +| 1 | **Backend not merged** | The entire implementation described in this document lives on `ppi/student-progress-endpoint` only. `feature/peer-progress-indicator` has none of it. | PPI-B01 | +| 2 | **Frontend target-grade parameter mismatch** | The current widget (`PpiWidgetComponent`, `feature/PPI-F03-ppi-widget-states`) calls `ppiService.getIndicator(taskDefId, unitId, targetGrade, mockState)` — it passes `this.task.project.targetGrade` from the browser. The real endpoint takes only `:id` and `:task_definition_id` and derives target grade itself; it does not accept one. Whoever wires the real HTTP call (PPI-F01) needs to drop the `targetGrade` argument from the service signature rather than forward it, otherwise it looks like the frontend is trying to supply a value the backend was deliberately designed to ignore. | PPI-F01 | +| 3 | **Two divergent frontend model/service names** | `feature/PPI-F03-ppi-widget-states` uses `PeerProgressIndicator` / `PeerProgressIndicatorService`. `feature/ppi-burndown-comparison` renames the same concept to `PeerProgress` / `PeerProgressService` in a separate, unmerged change. Neither branch is aware of the other's rename. Field names inside the interface are otherwise identical. | PPI-F01 | +| 4 | **Config values unset** | `DF_PPI_MINIMUM_COHORT_SIZE` and `DF_PPI_STALE_AFTER_HOURS` have no value anywhere in `doubtfire-deploy` (checked `development/api.env` and the compose files). The code deliberately fails closed (503) rather than assume a default. `DF_PPI_MINIMUM_COHORT_SIZE` additionally has a hardcoded floor of 5 (`MINIMUM_SAFE_COHORT_SIZE`) — even a configured value below 5 is rejected as a config error. That floor itself is a code-level decision, not yet confirmed with PPI-T01/PPI-S01. | PPI-T01 (approve values and the hardcoded floor) | +| 5 | **No unit has PPI enabled** | `units.peer_progress_enabled` defaults `false`, and no unit in the local dev database (including the `PPI1001` / `PPI1002` sample data created for dashboard testing) has it set. There is now a proper way to enable it (`PUT /units/:id` with `peer_progress_enabled: true`, convenor-only) rather than a manual DB flip — but it still hasn't been done for any test unit. Needs the privacy threshold (#4) set first, per the branch's own `docs/peer-progress-api.md`. | Whoever owns turning on the first test unit — likely PPI-B01 or PPI-S01 as part of review | +| 6 | **Placeholder wording** | `unavailable_message` strings are hardcoded in Ruby, written by whoever built PPI-B01, not reviewed for tone/wording. | PPI-D01 | +| 7 | **No independent privacy/authorisation review yet** | This document's field-exclusion check (§2) is a code read, not a security review. | PPI-S01 | +| 8 | **Backfill migration will blank every existing snapshot on deploy** | `add_target_grade_changed_at_to_projects` backfills every existing project's `target_grade_changed_at` to the migration's run time. Since the endpoint refuses any snapshot calculated *before* that timestamp, every student will see "unavailable" immediately after this migration deploys, until the next nightly `AggregatePeerProgressJob` run recalculates fresh snapshots. Not a bug, but worth knowing before flipping `peer_progress_enabled` on a unit right after a deploy — the first day will look broken. | PPI-B01 (deploy sequencing) | +| 9 | **Suppression and staleness are no longer mutually exclusive** | `is_suppressed` and `is_stale` can both be `true` in the same response as of the latest revision of this branch. The current frontend `resolvePeerProgressState` (`feature/PPI-F03-ppi-widget-states`) checks `isSuppressed` before `isStale` in an if/else chain, so a suppressed+stale response still resolves to the "hidden" UI state — consistent, but worth PPI-F01/PPI-F03 confirming that's the intended priority rather than an accident of write order. | PPI-F01 / PPI-F03 | + +--- + +## 6. Explicitly out of scope for this document + +This document does not implement the backend endpoint (PPI-B01), the frontend adapter (PPI-F01), the +unit-level component (PPI-F02), percentage calculation rules (PPI-T01), loading/error states (PPI-F03), +the independent security review (PPI-S01), or user-facing wording (PPI-D01). It does not create another +mock-data service or another minimal test-data task. Where this document identifies a security-relevant +boundary (§2, §5), that observation does not replace the independent PPI-S01 review. diff --git a/docs/peer-progress/task-completion-data-discovery.md b/docs/peer-progress/task-completion-data-discovery.md new file mode 100644 index 0000000000..4c9304dcf9 --- /dev/null +++ b/docs/peer-progress/task-completion-data-discovery.md @@ -0,0 +1,62 @@ +# PPI — Locate existing task-completion data in the API + +**Original ticket:** PPI - Locate existing task-completion data in the API (Discovery, starter task) +**Author:** Gaurav Manohar Myana +**Repo checked at the time:** `doubtfire-api`, branch `feature/peer-progress-indicator` + +> Preserved here, unedited from the original ticket deliverable, per PPI-D02's requirement to keep a +> link to the prior discovery work. See [data-source-map.md](./data-source-map.md) for how this +> compares against the actual PPI-B01 implementation found on `ppi/student-progress-endpoint`. + +## Purpose + +Find what task-completion data already exists in the API, so the Peer Progress Indicator isn't +designed around information that isn't actually available. + +## Relevant Rails models + +| Model | File | Relevant fields/notes | +|---|---|---| +| `Task` | `app/models/task.rb` | `task_status_id`, `completion_date`, `target_start_date`, `submission_date` | +| `TaskStatus` | `app/models/task_status.rb` | 15 fixed statuses (complete, working_on_it, fail, etc.) | +| `Project` (student's enrolment in a unit) | `app/models/project.rb` | `task_stats` (JSON): `{ red_pct, orange_pct, green_pct, blue_pct, grey_pct, order_scale }` — one student's own task-status mix | +| `Unit` | `app/models/unit.rb` | `#student_task_completion_stats` — cohort-wide median/min/max/quartile of completed tasks, broken down by tutorial and grade | + +## Relevant API endpoints + +| Endpoint | Access | Returns | +|---|---|---| +| `GET /projects/:id` | Authenticated user | Individual `task_stats` — **but hidden from the student themselves** (`unless: :for_student` in `ProjectEntity`) | +| `GET /units/:id/stats/task_completion_stats` | Staff only (`:download_stats`) | Cohort-wide completed-task stats (median/min/max/quartiles) by unit/tutorial/grade | +| `GET /units/:id/stats/task_completion_snapshots` | Staff only (`:download_stats`) | Historical point-in-time snapshots of status counts | + +## Data gap + +**No student-facing endpoint exposes any peer/cohort completion data**, and a student can't even see +their own `task_stats`. Confirmed in two places: + +1. `Unit.permissions` grants students only `[:get_unit]` — `:download_stats` is staff-only. +2. `ProjectEntity` explicitly excludes `task_stats` when the viewer is the student themselves. + +## Key finding + +The aggregation the PPI needs — anonymized cohort completed-task stats (median/quartiles by +tutorial/grade) — **already exists** in `Unit#student_task_completion_stats`. It does not need to be +built. It's just not reachable by students. + +## Recommended next step + +Add a new, student-authorised endpoint (e.g. `GET /units/:id/my_progress`) that returns the calling +student's own `task_stats` plus the cohort aggregate for their tutorial/grade, by reusing +`Unit#student_task_completion_stats` — without granting students the broader `:download_stats` +permission. + +## Blockers + +None. Scope was read-only exploration of the existing codebase; no production code changed. + +## What actually happened next (added retrospectively for PPI-D02) + +The recommendation above (reuse `Unit#student_task_completion_stats`) was **not** what PPI-B01 built. +See [data-source-map.md](./data-source-map.md) §1 "Divergence from the original discovery task" for +what was actually implemented instead, and why. From 91d4db957267ce3e80fbd977d577913e65fd15c8 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Thu, 20 Aug 2026 16:20:13 +1000 Subject: [PATCH 074/247] fix(ppi): stop the quantised percentage revealing the submitted count Quantising into buckets of B only hides the underlying count while B is strictly wider than one student's share of the cohort, 100.0 / cohort_size. A floor of 5 with a 5-point bucket satisfied neither side of that: for every cohort from 5 to 20 the mapping was injective and the returned percentage inverted to an exact submitted count. Move to a floor of 20 and a 10-point bucket, which leaves no cohort at or above the floor from which the count can be recovered, and add two tests that pin the relationship so the constants cannot drift apart again. Also scope the nightly aggregation to units that actually enabled the feature, rather than storing derived cohort statistics for every active unit and never serving them. Docs updated: the bucket size, the example response, the configuration floor, and the genuine-zero row, which is no longer true now that a small count also rounds to zero. --- app/api/peer_progress_api.rb | 15 +++- app/sidekiq/aggregate_peer_progress_job.rb | 14 +++- docs/peer-progress-api.md | 25 ++++-- test/api/peer_progress_api_test.rb | 80 +++++++++++++------ .../aggregate_peer_progress_job_test.rb | 31 ++++++- 5 files changed, 128 insertions(+), 37 deletions(-) diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb index f83cc450f9..639248904c 100644 --- a/app/api/peer_progress_api.rb +++ b/app/api/peer_progress_api.rb @@ -8,8 +8,19 @@ class PeerProgressApi < Grape::API UNAVAILABLE_MESSAGE = 'Peer progress is currently unavailable.' NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.' CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.' - MINIMUM_SAFE_COHORT_SIZE = 5 - PERCENTAGE_BUCKET_SIZE = 5.0 + # These two constants are a pair and must not be changed independently. + # + # Quantising into buckets of PERCENTAGE_BUCKET_SIZE only hides the underlying + # submitted count while a bucket is wider than one student's share of the + # cohort, which is 100.0 / cohort_size. Once PERCENTAGE_BUCKET_SIZE is less + # than or equal to 100.0 / MINIMUM_SAFE_COHORT_SIZE the mapping is injective + # and the returned percentage inverts to an exact submitted count -- the raw + # value the snapshot migration promises never to expose through this API. + # + # 20 and 10.0 leave no cohort size at or above the floor from which the count + # can be recovered. peer_progress_api_test.rb asserts the relationship holds. + MINIMUM_SAFE_COHORT_SIZE = 20 + PERCENTAGE_BUCKET_SIZE = 10.0 before do header 'Cache-Control', 'private, no-store' diff --git a/app/sidekiq/aggregate_peer_progress_job.rb b/app/sidekiq/aggregate_peer_progress_job.rb index b74236b133..9ad4a2bbcc 100644 --- a/app/sidekiq/aggregate_peer_progress_job.rb +++ b/app/sidekiq/aggregate_peer_progress_job.rb @@ -31,7 +31,11 @@ def enqueue_active_units 'Queueing peer progress aggregation for active units...' ) - Unit.active_units.find_each do |unit| + # Only units whose convenor has opted in. Aggregating the rest would store + # derived cohort statistics for units that never enabled the feature, and + # the endpoint returns early on peer_progress_enabled? so those rows could + # never be served anyway. + Unit.active_units.where(peer_progress_enabled: true).find_each do |unit| self.class.perform_async(unit.id) end @@ -48,6 +52,14 @@ def aggregate_unit(unit) return end + unless unit.peer_progress_enabled? + logger.info( + "Skipping peer progress aggregation for unit_id=#{unit.id}, " \ + 'peer progress is not enabled' + ) + return + end + logger.info( "Starting peer progress aggregation for unit_id=#{unit.id}..." ) diff --git a/docs/peer-progress-api.md b/docs/peer-progress-api.md index 26955b7bfa..6a260040df 100644 --- a/docs/peer-progress-api.md +++ b/docs/peer-progress-api.md @@ -8,8 +8,6 @@ The route is restricted to the authenticated student who owns the enrolled proje The unit and target grade are derived from that project. The route does not accept a student ID, unit ID, trimester, cohort, or target grade from the browser. -## Response fields - ## Successful response contract All authorised business states return HTTP 200 with exactly the following @@ -28,9 +26,20 @@ camelCase interface. | `last_updated_at` | String | Yes | UTC ISO 8601 snapshot time, or `null` when no snapshot was used | | `unavailable_message` | String | No | Empty on success; otherwise a neutral and privacy-safe message | -A genuine zero is returned as `0.0`. It is not treated as missing data. +Student-facing percentages are quantised to the nearest ten percentage points. +The precise stored aggregate is never returned by this API. + +The bucket size and the minimum cohort size are a matched pair. Quantising only +hides the underlying submitted count while a bucket is strictly wider than one +student's share of the cohort, which is `100.0 / cohort_size`. With a floor of +20 and a bucket of 10, no cohort at or above the floor allows the count to be +recovered from the percentage. Changing either number alone breaks that, so the +relationship is asserted in `test/api/peer_progress_api_test.rb`. -Student-facing percentages are quantised to the nearest five percentage points. The precise stored aggregate is not returned by this API. +Quantisation applies to `0.0` and `100.0` as well. A cohort where nobody has +submitted and a cohort where fewer than one bucket's worth have submitted both +return `0.0`, so `0.0` means "at most a rounding bucket", not "exactly none". +The same holds at the top of the range. `submitted_percentage` must be `null` for suppressed, stale, disabled and unavailable states. The response never includes raw cohort size or submitted @@ -41,7 +50,7 @@ count. | State | Percentage | Suppressed | Stale | Enabled | Last updated | | --- | --- | --- | --- | --- | --- | | Normal | Number | False | False | True | Timestamp | -| Genuine zero | `0.0` | False | False | True | Timestamp | +| Rounds to zero | `0.0` | False | False | True | Timestamp | | Empty or small cohort, fresh | `null` | True | False | True | Timestamp | | Empty or small cohort, stale | `null` | True | True | True | Timestamp | | Stale snapshot | `null` | False | True | True | Timestamp | @@ -55,7 +64,7 @@ count. "task_definition_id": 12, "unit_id": 5, "target_grade": 2, - "submitted_percentage": 65.0, + "submitted_percentage": 60.0, "is_suppressed": false, "is_stale": false, "is_feature_enabled": true, @@ -64,7 +73,7 @@ count. } ``` -### Genuine zero +### Rounds to zero ``` json { "task_definition_id": 12, @@ -192,7 +201,7 @@ creates a snapshot for that grade. - `DF_PPI_MINIMUM_COHORT_SIZE`: approved minimum cohort size. - `DF_PPI_STALE_AFTER_HOURS`: approved maximum snapshot age. -`DF_PPI_STALE_AFTER_HOURS` must be a positive integer. `DF_PPI_MINIMUM_COHORT_SIZE` must be an integer of at least `5`. No production defaults are included. An enabled unit +`DF_PPI_STALE_AFTER_HOURS` must be a positive integer. `DF_PPI_MINIMUM_COHORT_SIZE` must be an integer of at least `PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE`, which is `20`. A lower value is rejected rather than honoured, so configuration alone cannot defeat suppression. No production defaults are included. An enabled unit with a valid snapshot fails closed with HTTP 503 when either value is missing or invalid. ## Feature enablement diff --git a/test/api/peer_progress_api_test.rb b/test/api/peer_progress_api_test.rb index a9e0df2ba1..e48bc0fe96 100644 --- a/test/api/peer_progress_api_test.rb +++ b/test/api/peer_progress_api_test.rb @@ -42,7 +42,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase @original_stale_after_hours = ENV.fetch('DF_PPI_STALE_AFTER_HOURS', nil) - ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '5' + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '20' ENV['DF_PPI_STALE_AFTER_HOURS'] = '48' @unit = create( @@ -95,7 +95,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'returns a privacy-safe normal response for the owning student' do create_snapshot( submitted_percentage: 62.5, - cohort_size: 5 + cohort_size: 20 ) request_as(@student) @@ -107,7 +107,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal @task_definition.id, body['task_definition_id'] assert_equal @unit.id, body['unit_id'] assert_equal @project.target_grade, body['target_grade'] - assert_equal 65.0, body['submitted_percentage'] + assert_equal 60.0, body['submitted_percentage'] assert_equal false, body['is_suppressed'] assert_equal false, body['is_stale'] assert_equal true, body['is_feature_enabled'] @@ -118,7 +118,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'returns a genuine zero as zero rather than unavailable' do create_snapshot( submitted_percentage: 0, - cohort_size: 5 + cohort_size: 20 ) request_as(@student) @@ -176,7 +176,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase create_snapshot( submitted_percentage: 50, - cohort_size: 5 + cohort_size: 20 ) request_as(@student) @@ -188,7 +188,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'does not create a task row while checking the release date' do create_snapshot( submitted_percentage: 50, - cohort_size: 5 + cohort_size: 20 ) assert_no_difference('Task.count') do @@ -198,10 +198,10 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal 200, last_response.status end - test 'quantises the student percentage to five point buckets' do + test 'quantises the student percentage to ten point buckets' do create_snapshot( submitted_percentage: 61, - cohort_size: 10 + cohort_size: 20 ) request_as(@student) @@ -213,10 +213,10 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'fails closed when the cohort configuration is below the privacy floor' do create_snapshot( submitted_percentage: 50, - cohort_size: 5 + cohort_size: 20 ) - ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '4' + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '19' request_as(@student) @@ -229,11 +229,11 @@ class PeerProgressApiTest < ActiveSupport::TestCase end test 'accepts a configured threshold above the privacy floor' do - ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '6' + ENV['DF_PPI_MINIMUM_COHORT_SIZE'] = '21' create_snapshot( submitted_percentage: 50, - cohort_size: 6 + cohort_size: 21 ) request_as(@student) @@ -362,7 +362,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'suppresses a cohort below the configured threshold' do create_snapshot( submitted_percentage: 50, - cohort_size: 4 + cohort_size: 19 ) request_as(@student) @@ -381,7 +381,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'shows a cohort at the exact configured threshold' do create_snapshot( submitted_percentage: 40, - cohort_size: 5 + cohort_size: 20 ) request_as(@student) @@ -394,10 +394,44 @@ class PeerProgressApiTest < ActiveSupport::TestCase assert_equal false, body['is_suppressed'] end + test 'keeps the bucket wider than one students share of the smallest cohort' do + # Quantising only hides the submitted count while a bucket is strictly + # wider than 100.0 / cohort_size. If these two constants ever drift so that + # the bucket is no larger than one student's share, the returned percentage + # becomes injective and inverts to an exact count. + assert_operator( + PeerProgressApi::PERCENTAGE_BUCKET_SIZE, + :>, + 100.0 / PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE, + 'PERCENTAGE_BUCKET_SIZE must exceed 100.0 / MINIMUM_SAFE_COHORT_SIZE, ' \ + 'or the quantised percentage reveals the exact submitted count' + ) + end + + test 'does not let the quantised percentage reveal the submitted count' do + # Walk every submitted count for the smallest permitted cohort and assert + # at least one collision, i.e. the mapping is not reversible. + cohort_size = PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + bucket = PeerProgressApi::PERCENTAGE_BUCKET_SIZE + + quantised = (0..cohort_size).map do |submitted| + exact = ((submitted * 100.0) / cohort_size).round(2) + ((exact / bucket).round * bucket).to_f + end + + assert_operator( + quantised.uniq.length, + :<, + quantised.length, + "every submitted count from 0 to #{cohort_size} maps to a distinct " \ + 'percentage, so the count is recoverable' + ) + end + test 'hides the percentage when an active unit snapshot is stale' do create_snapshot( submitted_percentage: 50, - cohort_size: 5, + cohort_size: 20, calculated_at: 49.hours.ago ) @@ -434,7 +468,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'ignores a browser supplied target grade' do create_snapshot( submitted_percentage: 60, - cohort_size: 5 + cohort_size: 20 ) request_as( @@ -527,7 +561,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase create_snapshot( submitted_percentage: 62.5, - cohort_size: 5, + cohort_size: 20, calculated_at: calculated_at ) @@ -547,7 +581,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'fails closed when the stale window configuration is missing' do create_snapshot( submitted_percentage: 50, - cohort_size: 5 + cohort_size: 20 ) ENV.delete('DF_PPI_STALE_AFTER_HOURS') @@ -587,7 +621,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'fails closed for invalid positive integer configuration' do create_snapshot( submitted_percentage: 50, - cohort_size: 5 + cohort_size: 20 ) [ @@ -618,7 +652,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase travel_to Time.zone.parse('2026-08-10 12:00:00 UTC') do create_snapshot( submitted_percentage: 50, - cohort_size: 5, + cohort_size: 20, calculated_at: 48.hours.ago ) @@ -639,7 +673,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase create_snapshot( target_grade: 2, submitted_percentage: 60, - cohort_size: 5, + cohort_size: 20, calculated_at: 1.hour.ago ) @@ -690,7 +724,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase test 'fails closed when required PPI configuration is missing' do create_snapshot( submitted_percentage: 50, - cohort_size: 5 + cohort_size: 20 ) ENV.delete('DF_PPI_MINIMUM_COHORT_SIZE') @@ -713,7 +747,7 @@ class PeerProgressApiTest < ActiveSupport::TestCase create_snapshot( target_grade: 2, submitted_percentage: 61, - cohort_size: 5, + cohort_size: 20, calculated_at: Time.zone.now ) diff --git a/test/sidekiq/aggregate_peer_progress_job_test.rb b/test/sidekiq/aggregate_peer_progress_job_test.rb index 18dae89cac..95e2c22fcc 100644 --- a/test/sidekiq/aggregate_peer_progress_job_test.rb +++ b/test/sidekiq/aggregate_peer_progress_job_test.rb @@ -7,6 +7,10 @@ class AggregatePeerProgressJobTest < ActiveSupport::TestCase def setup @active_unit = create_minimal_unit(active: true) @inactive_unit = create_minimal_unit(active: false) + @disabled_unit = create_minimal_unit( + active: true, + peer_progress_enabled: false + ) @calculated_at = Time.zone.parse('2026-08-10 23:45:00') end @@ -33,11 +37,14 @@ def test_aggregates_the_requested_active_unit assert_equal @calculated_at, calls.first[:calculated_at] end - def test_enqueues_one_job_for_each_active_unit_when_no_unit_id_is_given + def test_enqueues_one_job_for_each_enabled_active_unit_when_no_unit_id_is_given Sidekiq::Job.clear_all expected_unit_ids = - Unit.active_units.order(:id).pluck(:id) + Unit.active_units + .where(peer_progress_enabled: true) + .order(:id) + .pluck(:id) assert_difference( -> { AggregatePeerProgressJob.jobs.size }, @@ -54,6 +61,7 @@ def test_enqueues_one_job_for_each_active_unit_when_no_unit_id_is_given assert_equal expected_unit_ids, actual_unit_ids assert_not_includes actual_unit_ids, @inactive_unit.id + assert_not_includes actual_unit_ids, @disabled_unit.id end def test_failure_for_one_unit_does_not_prevent_another_unit_job @@ -97,6 +105,22 @@ def test_skips_a_requested_inactive_unit assert_empty calls end + def test_skips_a_requested_unit_with_peer_progress_disabled + calls = [] + + PeerProgressAggregationService.stub( + :call, + lambda do |unit:, calculated_at:| + calls << [unit, calculated_at] + [] + end + ) do + AggregatePeerProgressJob.new.perform(@disabled_unit.id) + end + + assert_empty calls + end + def test_raises_when_requested_unit_does_not_exist missing_unit_id = Unit.maximum(:id).to_i + 10_000 @@ -172,10 +196,11 @@ def test_creates_a_snapshot_through_the_real_aggregation_service private - def create_minimal_unit(active:) + def create_minimal_unit(active:, peer_progress_enabled: true) create( :unit, active: active, + peer_progress_enabled: peer_progress_enabled, with_students: false, task_count: 0, stream_count: 0, From 21117c49b924792d430a654a1710da4d86df10b3 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Thu, 20 Aug 2026 22:07:18 +1000 Subject: [PATCH 075/247] docs(notifications): lead the mail section with Mailpit Development mail goes to Mailpit on localhost:8025 because the dev stack sets DF_SMTP_ADDRESS. The file drop is the fallback when it is not set, and under Docker those land in doubtfire-deploy/data/tmp/mails rather than doubtfire-api/tmp/mails, which is why email looks broken to anyone checking the wrong folder. --- docs/notifications/CONTRIBUTING.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/notifications/CONTRIBUTING.md b/docs/notifications/CONTRIBUTING.md index 3340695e5c..b3866e7766 100644 --- a/docs/notifications/CONTRIBUTING.md +++ b/docs/notifications/CONTRIBUTING.md @@ -329,11 +329,18 @@ Ignore it. This project does not use RSpec, and the handover document that says it does is a trimester out of date. The same document says Angular 17 and Karma. It is Angular 22 and vitest. -Development mail is written to files, not sent. It lands in -`doubtfire-deploy/data/tmp/mails/`, **not** `doubtfire-api/tmp/mails` as the -comment in `config/environments/development.rb` claims. The container mounts -`../data/tmp` over `/doubtfire/tmp`, so the comment is wrong under Docker. Mailpit -on port 8025 is the easier way to look at them. +Development mail goes to **Mailpit, on `http://localhost:8025`**. The dev stack +starts it and sets `DF_SMTP_ADDRESS`, so `config/environments/development.rb` +takes the SMTP path and everything the app sends turns up there. That is the +easiest way to check an email actually went out, and a Mailpit screenshot is +good evidence on a ticket. + +If `DF_SMTP_ADDRESS` is not set, Rails falls back to writing mail to a file +instead. Under Docker those land on the host at +`doubtfire-deploy/data/tmp/mails/`, not under `doubtfire-api/tmp/mails`, because +the compose file mounts `../data/tmp` over `/doubtfire/tmp`. Looking in the wrong +one shows an empty folder and makes email look broken. The comment in +`development.rb` explains this too. --- From da963129c1908a18baee6beadf9e89099505514b Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Thu, 20 Aug 2026 22:45:44 +1000 Subject: [PATCH 076/247] feat(push): add safe notification click contract --- app/services/push_notification_service.rb | 71 ++++++---- .../push_notification_service_test.rb | 123 +++++++++++++++++- 2 files changed, 165 insertions(+), 29 deletions(-) diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb index 6ea220ae9b..034acb17ff 100644 --- a/app/services/push_notification_service.rb +++ b/app/services/push_notification_service.rb @@ -18,6 +18,21 @@ class PushNotificationService # task titles, so it is trimmed rather than trusted. MAX_BODY_LENGTH = 400 + # MN-C03 BEGIN: safe click route constants + SAFE_CLICK_FALLBACK = '/notifications'.freeze + MAX_CLICK_LINK_LENGTH = 256 + FORBIDDEN_CLICK_LINK_TEXT = /[\u0000-\u001f\u007f\s\\?#%]/ + SAFE_PROJECT_ROOT_LINK = %r{\A/projects/[1-9]\d*/(?:dashboard|groups)\z} + SAFE_PROJECT_TASK_LINK = %r{ + \A/projects/[1-9]\d*/dashboard/ + (?[A-Za-z0-9][A-Za-z0-9._-]{0,31})\z + }x + EXPECTED_TASK_ABBREVIATION = / + \A(?=.{1,32}\z)(?=.*\d)(?=.*(?:\.|[A-Z])) + (?:HD|P|C|D|T)?\d+(?:\.\d+)*(?:HD|P|C|D|T)?\z + /x + SENSITIVE_TASK_TEXT = /(feedback|token|mark|grade|student|learner|name|comment)/i + # MN-C03 END: safe click route constants # Seconds. web-push sets no timeouts of its own, so without these a push # service that accepts a connection and then never answers holds the request # thread open until the app server kills it. NotificationService calls this @@ -53,6 +68,8 @@ def self.deliver(notification) # option names (ngsw-worker.js, NOTIFICATION_OPTION_NAMES), so they reach # showNotification without any change on the web side. def self.payload_for(notification) + click_link = safe_click_link(notification.link) + { notification: { title: Doubtfire::Application.config.institution[:product_name], @@ -75,38 +92,40 @@ def self.payload_for(notification) renotify: false, data: { notification_id: notification.id, - link: notification.link + link: click_link, + onActionClick: { + default: { + operation: 'focusLastFocusedOrOpen', + url: click_link + } + } } } }.to_json end - # What the operating system collapses on. - # - # Per conversation and not per notification. Two notifications sharing a tag - # means the second replaces the first on screen rather than stacking beneath - # it, so the tag has to name the thing being discussed and nothing that - # changes between messages about it. The notification id would be unique every - # time and collapse nothing at all. - # - # The event plus the link. The link is the only handle the api has on the - # subject, `/projects/9/dashboard/T1.1` being one student's copy of one task. - # The event is what makes it a conversation rather than a topic: a run of - # comments on a task is one thing being said repeatedly, and a deadline change - # to the same task is something else that must not quietly replace it. - # - # notification_type is deliberately not used here. It is the preference - # category, so task_due_date_changed, task_status_changed, new_task_available - # and task_due_soon are all `task`, and keying on it would let a status change - # silently take the place of a deadline alert about the same task. - # - # Without a link there is nothing to be about, so this falls back to the id - # and that notification collapses with nothing. That is the safe direction: - # sharing a tag between unrelated notifications would hide one behind another. + def self.safe_click_link(link) + return SAFE_CLICK_FALLBACK unless link.is_a?(String) + return SAFE_CLICK_FALLBACK if link.empty? || link.length > MAX_CLICK_LINK_LENGTH + return SAFE_CLICK_FALLBACK unless link == link.strip + return SAFE_CLICK_FALLBACK if link.match?(FORBIDDEN_CLICK_LINK_TEXT) + return link if link == SAFE_CLICK_FALLBACK || link.match?(SAFE_PROJECT_ROOT_LINK) + + task_match = SAFE_PROJECT_TASK_LINK.match(link) + return SAFE_CLICK_FALLBACK if task_match.nil? + + task = task_match[:task] + return SAFE_CLICK_FALLBACK unless task.match?(EXPECTED_TASK_ABBREVIATION) + return SAFE_CLICK_FALLBACK if task.match?(SENSITIVE_TASK_TEXT) + + link + end + def self.tag_for(notification) - return "notification-#{notification.id}" if notification.link.blank? + click_link = safe_click_link(notification.link) + return "notification-#{notification.id}" unless click_link == notification.link - "#{notification.event}:#{notification.link}" + "#{notification.event}:#{click_link}" end def self.deliver_to(subscription, payload) @@ -163,5 +182,5 @@ def self.configured? ENV['DOUBTFIRE_VAPID_PUBLIC_KEY'].present? && ENV['DOUBTFIRE_VAPID_PRIVATE_KEY'].present? end - private_class_method :deliver_to, :vapid_details, :vapid_subject + private_class_method :safe_click_link, :deliver_to, :vapid_details, :vapid_subject end diff --git a/test/services/push_notification_service_test.rb b/test/services/push_notification_service_test.rb index 15bb1a3a41..d3a506189a 100644 --- a/test/services/push_notification_service_test.rb +++ b/test/services/push_notification_service_test.rb @@ -64,6 +64,10 @@ def tag_for(notification) JSON.parse(PushNotificationService.payload_for(notification))['notification']['tag'] end + def click_data(notification = @notification) + JSON.parse(PushNotificationService.payload_for(notification)).dig('notification', 'data') + end + def test_nothing_is_sent_when_the_vapid_keys_are_missing create_subscription @@ -154,12 +158,125 @@ def test_the_payload_has_the_shape_angulars_service_worker_expects assert_equal 'Andrew Cain commented on 1.1P in COS10001.', body['body'] assert_equal '/projects/2/dashboard/1.1P', body.dig('data', 'link') assert_equal @notification.id, body.dig('data', 'notification_id') + assert_equal 'focusLastFocusedOrOpen', + body.dig('data', 'onActionClick', 'default', 'operation') + assert_equal '/projects/2/dashboard/1.1P', + body.dig('data', 'onActionClick', 'default', 'url') assert_not_nil body['title'] end - # MN-C06. The operating system does the collapsing, and it collapses on the - # tag. Two notifications carrying the same tag means the second replaces the - # first on screen instead of stacking under it. + + def test_click_payload_preserves_every_approved_route_family + routes = [ + '/notifications', + '/projects/2/dashboard', + '/projects/2/groups', + '/projects/2/dashboard/1.1P', + '/projects/2/dashboard/T1.1', + '/projects/2/dashboard/HD1.2' + ] + + routes.each do |route| + @notification.link = route + data = click_data + + assert_equal route, data['link'] + assert_equal route, data.dig('onActionClick', 'default', 'url') + assert_equal 'focusLastFocusedOrOpen', + data.dig('onActionClick', 'default', 'operation') + end + end + + def test_click_payload_falls_back_for_missing_malformed_external_and_encoded_links + invalid_links = [ + nil, + '', + ' ', + 'http://example.test/projects/2/dashboard', + 'https://example.test/projects/2/dashboard', + 'mailto:student@example.test', + 'javascript:alert(1)', + 'data:text/html,unsafe', + 'file:///etc/passwd', + '//example.test/projects/2/dashboard', + '\\example.test\\projects\\2', + '/projects/2\\dashboard', + '/%2f%2fexample.test', + '/%5cexample.test', + '/projects/2/dashboard/%31.1P', + '/projects/2/dashboard/1.1P%3ftoken%3dsecret', + '/%252f%252fexample.test', + '/projects/2/dashboard/%', + '/projects/0/dashboard', + '/projects/2/dashboard/', + '/projects/2/dashboard/1.1P/extra', + '/units/2', + '/home' + ] + + invalid_links.each do |link| + @notification.link = link + data = click_data + + assert_equal '/notifications', data['link'], "expected fallback for #{link.inspect}" + assert_equal '/notifications', data.dig('onActionClick', 'default', 'url') + end + end + + def test_an_unsafe_link_is_not_copied_into_the_push_tag + @notification.link = '/projects/2/dashboard/token123' + + tag = tag_for(@notification) + + assert_equal "notification-#{@notification.id}", tag + refute_includes tag, 'token123' + end + + def test_click_payload_does_not_place_sensitive_values_in_routes + invalid_links = [ + '/projects/2/dashboard/85', + '/projects/2/dashboard/Alice1', + '/projects/2/dashboard/BOB1', + '/projects/2/dashboard/1.1ALICE', + '/projects/2/dashboard/1.1BOB', + '/projects/2/dashboard/feedback1', + '/projects/2/dashboard/token123', + '/projects/2/dashboard/mark85', + '/projects/2/dashboard/1.1P?token=secret', + '/projects/2/dashboard/1.1P#feedback', + "/projects/2/dashboard/#{'A1' * 20}" + ] + + invalid_links.each do |link| + @notification.link = link + data = click_data + + assert_equal '/notifications', data['link'], "expected fallback for #{link.inspect}" + assert_equal '/notifications', data.dig('onActionClick', 'default', 'url') + end + end + + def test_click_payload_preserves_each_approved_route_family + routes = [ + '/notifications', + '/projects/2/dashboard', + '/projects/2/groups', + '/projects/2/dashboard/1.1P', + '/projects/2/dashboard/T1.1', + '/projects/2/dashboard/HD1.2' + ] + + routes.each do |route| + @notification.link = route + data = click_data + + assert_equal route, data['link'] + assert_equal route, data.dig('onActionClick', 'default', 'url') + assert_equal 'focusLastFocusedOrOpen', + data.dig('onActionClick', 'default', 'operation') + end + end + def test_two_notifications_about_the_same_thing_share_a_tag second = Notification.create!( user: @user, From 693667919651ba914f738b862ba021ca76f0a0f6 Mon Sep 17 00:00:00 2001 From: Maple 'Ryan' Fox Date: Fri, 21 Aug 2026 01:26:48 +1000 Subject: [PATCH 077/247] chore(ci): notify Teams when a pull request opens * Add configurable marker notification thresholds * fix(ci): gate the Teams notifier and harden its payload Adds a job guard so the webhook is unreachable from anonymous fork pull requests on these public repositories, and so the file is inert if it ever travels to thoth-tech or doubtfire-lms. Adds reopened and ready_for_review, which is the transition a reviewer alert exists to catch, and varies the headline per action so it stays accurate. Strips Markdown link and code syntax from the title, author and head label before they reach the channel. Joins on a double newline, which is what Teams renders as a break. Reports the real HTTP status instead of asserting a delivery the workflow never checked. --------- Co-authored-by: Clupai8o0 --- .github/workflows/notify-teams-pr.yml | 140 ++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 .github/workflows/notify-teams-pr.yml diff --git a/.github/workflows/notify-teams-pr.yml b/.github/workflows/notify-teams-pr.yml new file mode 100644 index 0000000000..507bd01c3d --- /dev/null +++ b/.github/workflows/notify-teams-pr.yml @@ -0,0 +1,140 @@ +# NOTE ON THE BASE BRANCH. GitHub loads a `pull_request_target` workflow from the +# repository DEFAULT branch (11.0.x), not from the pull request's base branch. That +# changed on 2025-12-08. So this file is merged into 11.0.x deliberately, against the +# usual CONTRIBUTING rule, and a copy on feature/notifications would be dead code. +# It fires for pull requests into every base branch, which is what we want. +name: Notify Teams when a pull request opens + +on: + pull_request_target: + types: + - opened + - reopened + - ready_for_review + # - review_requested # matches "reviewer alerts" most directly, but adds one + # # message per requested reviewer. Decide, do not default. + +# Removes all GITHUB_TOKEN scopes, which this workflow does not need. It does NOT +# restrict secrets.* - the webhook is protected by the job guard below, not by this. +permissions: {} + +jobs: + notify-teams: + name: Post pull request notification to Teams + # First clause makes the file inert if it ever travels to thoth-tech or + # doubtfire-lms. Second keeps the webhook out of reach of anonymous fork PRs + # while still notifying teammates who work from their own forks, which + # several doubtfire-web contributors do. + if: >- + github.repository_owner == 'ontrack-features-t2-2026' && + (github.event.pull_request.head.repo.full_name == github.repository || + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)) + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Build and send Teams notification + shell: bash + env: + TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_PR_WEBHOOK_URL }} + PAYLOAD_PATH: ${{ runner.temp }}/teams-pr-notification.json + + REPOSITORY: ${{ github.repository }} + PR_ACTION: ${{ github.event.action }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + PR_HEAD: ${{ github.event.pull_request.head.label }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + + run: | + set -euo pipefail + + if [[ -z "${TEAMS_WEBHOOK_URL:-}" ]]; then + echo "::error::The TEAMS_PR_WEBHOOK_URL Actions secret is not configured." + exit 1 + fi + + python3 - <<'PY' + import json + import os + import re + + + def plain(value): + """Teams renders these fields as Markdown, and titles and branch names + come from anyone who can open a pull request. Removing [ ] < > and + backticks stops a title forging a Markdown link or code span. + Parentheses are left alone so `feat(scope): ...` still reads + properly. Note this does NOT stop Teams auto-linking a bare URL in + a title, it only stops the link TEXT being controlled.""" + value = " ".join(value.split()) + return re.sub(r"[\[\]<>`]", " ", value)[:200] + + + headline = { + "opened": "New pull request opened", + "reopened": "Pull request reopened", + "ready_for_review": "Pull request is ready for review", + }.get(os.environ["PR_ACTION"], "Pull request updated") + + status = ( + "Draft" + if os.environ.get("PR_DRAFT", "").lower() == "true" + else "Ready for review" + ) + + # Teams renders a break for "\n\n" and not for a single "\n". + text = "\n\n".join( + [ + headline, + f"Repository: {os.environ['REPOSITORY']}", + f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", + f"Author: {plain(os.environ['PR_AUTHOR'])}", + f"Status: {status}", + f"Branches: {plain(os.environ['PR_HEAD'])} -> {os.environ['PR_BASE']}", + os.environ["PR_URL"], + ] + ) + + with open( + os.environ["PAYLOAD_PATH"], + "w", + encoding="utf-8", + ) as payload_file: + json.dump( + {"text": text}, + payload_file, + ensure_ascii=False, + ) + PY + + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Content-Type: application/json' \ + --data-binary "@${PAYLOAD_PATH}" \ + --output "${RUNNER_TEMP}/teams-response.txt" \ + --write-out '%{http_code}' \ + --url "${TEAMS_WEBHOOK_URL}" + )" || http_status="000" + + # Uncomment to debug a failing webhook. The body comes from a third party + # and lands in a PUBLIC Actions log, and GitHub only masks an exact + # full-value match of a secret. Read it once, then comment it out again. + # cat "${RUNNER_TEMP}/teams-response.txt" + + if [[ "${http_status}" != 2* ]]; then + echo "::error::Teams webhook returned HTTP ${http_status}." + exit 1 + fi + + echo "Teams webhook returned HTTP ${http_status} for PR #${PR_NUMBER}." + echo "That means the request was accepted. It does not prove the message rendered in the channel." From b8920d8f34595d62adc68ebc2cb6b37b4cbb75bd Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Fri, 21 Aug 2026 10:56:19 +1000 Subject: [PATCH 078/247] fix(push): accept bounded notification task routes --- app/services/push_notification_service.rb | 30 +++++------- .../push_notification_service_test.rb | 47 ++++++------------- 2 files changed, 28 insertions(+), 49 deletions(-) diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb index 034acb17ff..5bf211ab30 100644 --- a/app/services/push_notification_service.rb +++ b/app/services/push_notification_service.rb @@ -23,15 +23,7 @@ class PushNotificationService MAX_CLICK_LINK_LENGTH = 256 FORBIDDEN_CLICK_LINK_TEXT = /[\u0000-\u001f\u007f\s\\?#%]/ SAFE_PROJECT_ROOT_LINK = %r{\A/projects/[1-9]\d*/(?:dashboard|groups)\z} - SAFE_PROJECT_TASK_LINK = %r{ - \A/projects/[1-9]\d*/dashboard/ - (?[A-Za-z0-9][A-Za-z0-9._-]{0,31})\z - }x - EXPECTED_TASK_ABBREVIATION = / - \A(?=.{1,32}\z)(?=.*\d)(?=.*(?:\.|[A-Z])) - (?:HD|P|C|D|T)?\d+(?:\.\d+)*(?:HD|P|C|D|T)?\z - /x - SENSITIVE_TASK_TEXT = /(feedback|token|mark|grade|student|learner|name|comment)/i + SAFE_PROJECT_TASK_LINK = %r{\A/projects/[1-9]\d*/dashboard/[A-Za-z0-9][A-Za-z0-9._-]{0,31}\z}x # MN-C03 END: safe click route constants # Seconds. web-push sets no timeouts of its own, so without these a push # service that accepts a connection and then never answers holds the request @@ -110,17 +102,21 @@ def self.safe_click_link(link) return SAFE_CLICK_FALLBACK unless link == link.strip return SAFE_CLICK_FALLBACK if link.match?(FORBIDDEN_CLICK_LINK_TEXT) return link if link == SAFE_CLICK_FALLBACK || link.match?(SAFE_PROJECT_ROOT_LINK) + return link if link.match?(SAFE_PROJECT_TASK_LINK) - task_match = SAFE_PROJECT_TASK_LINK.match(link) - return SAFE_CLICK_FALLBACK if task_match.nil? - - task = task_match[:task] - return SAFE_CLICK_FALLBACK unless task.match?(EXPECTED_TASK_ABBREVIATION) - return SAFE_CLICK_FALLBACK if task.match?(SENSITIVE_TASK_TEXT) - - link + SAFE_CLICK_FALLBACK end + # Use the event and validated destination as the collapse key so repeated + # pushes about the same event and task can replace one banner. + # + # notification_type is intentionally not used because several different + # events share one category. For example, a task status change must not + # silently replace a due-date warning about the same task. + # + # A missing or rejected destination receives a notification-specific tag. + # This prevents unrelated downgraded notifications from replacing each other + # and prevents the rejected raw route from being copied into the tag. def self.tag_for(notification) click_link = safe_click_link(notification.link) return "notification-#{notification.id}" unless click_link == notification.link diff --git a/test/services/push_notification_service_test.rb b/test/services/push_notification_service_test.rb index d3a506189a..c72913b740 100644 --- a/test/services/push_notification_service_test.rb +++ b/test/services/push_notification_service_test.rb @@ -165,7 +165,6 @@ def test_the_payload_has_the_shape_angulars_service_worker_expects assert_not_nil body['title'] end - def test_click_payload_preserves_every_approved_route_family routes = [ '/notifications', @@ -173,7 +172,13 @@ def test_click_payload_preserves_every_approved_route_family '/projects/2/groups', '/projects/2/dashboard/1.1P', '/projects/2/dashboard/T1.1', - '/projects/2/dashboard/HD1.2' + '/projects/2/dashboard/HD1.2', + '/projects/2/dashboard/10.1H', + '/projects/2/dashboard/A15', + '/projects/2/dashboard/TASK1', + '/projects/2/dashboard/P-2.21', + '/projects/2/dashboard/D-9.568', + '/projects/2/dashboard/C-4.602' ] routes.each do |route| @@ -210,6 +215,9 @@ def test_click_payload_falls_back_for_missing_malformed_external_and_encoded_lin '/projects/0/dashboard', '/projects/2/dashboard/', '/projects/2/dashboard/1.1P/extra', + '/projects/2/dashboard/1.1P?token=secret', + '/projects/2/dashboard/1.1P#feedback', + "/projects/2/dashboard/#{'A1' * 20}", '/units/2', '/home' ] @@ -224,16 +232,15 @@ def test_click_payload_falls_back_for_missing_malformed_external_and_encoded_lin end def test_an_unsafe_link_is_not_copied_into_the_push_tag - @notification.link = '/projects/2/dashboard/token123' + @notification.link = 'https://example.test/unsafe' tag = tag_for(@notification) - assert_equal "notification-#{@notification.id}", tag - refute_includes tag, 'token123' + assert_not_includes tag, 'example.test' end - def test_click_payload_does_not_place_sensitive_values_in_routes - invalid_links = [ + def test_click_payload_preserves_bounded_task_segments_without_guessing_their_format + routes = [ '/projects/2/dashboard/85', '/projects/2/dashboard/Alice1', '/projects/2/dashboard/BOB1', @@ -241,29 +248,7 @@ def test_click_payload_does_not_place_sensitive_values_in_routes '/projects/2/dashboard/1.1BOB', '/projects/2/dashboard/feedback1', '/projects/2/dashboard/token123', - '/projects/2/dashboard/mark85', - '/projects/2/dashboard/1.1P?token=secret', - '/projects/2/dashboard/1.1P#feedback', - "/projects/2/dashboard/#{'A1' * 20}" - ] - - invalid_links.each do |link| - @notification.link = link - data = click_data - - assert_equal '/notifications', data['link'], "expected fallback for #{link.inspect}" - assert_equal '/notifications', data.dig('onActionClick', 'default', 'url') - end - end - - def test_click_payload_preserves_each_approved_route_family - routes = [ - '/notifications', - '/projects/2/dashboard', - '/projects/2/groups', - '/projects/2/dashboard/1.1P', - '/projects/2/dashboard/T1.1', - '/projects/2/dashboard/HD1.2' + '/projects/2/dashboard/mark85' ] routes.each do |route| @@ -272,8 +257,6 @@ def test_click_payload_preserves_each_approved_route_family assert_equal route, data['link'] assert_equal route, data.dig('onActionClick', 'default', 'url') - assert_equal 'focusLastFocusedOrOpen', - data.dig('onActionClick', 'default', 'operation') end end From e506e40f98ede239a0ec7e7c9c9deab39d599ddd Mon Sep 17 00:00:00 2001 From: Maple 'Ryan' Fox Date: Fri, 21 Aug 2026 11:33:58 +1000 Subject: [PATCH 079/247] Refactor Teams notification to use Adaptive Card format --- .github/workflows/notify-teams-pr.yml | 75 ++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/.github/workflows/notify-teams-pr.yml b/.github/workflows/notify-teams-pr.yml index 507bd01c3d..5cf0be21f6 100644 --- a/.github/workflows/notify-teams-pr.yml +++ b/.github/workflows/notify-teams-pr.yml @@ -86,18 +86,67 @@ jobs: else "Ready for review" ) - # Teams renders a break for "\n\n" and not for a single "\n". - text = "\n\n".join( - [ - headline, - f"Repository: {os.environ['REPOSITORY']}", - f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", - f"Author: {plain(os.environ['PR_AUTHOR'])}", - f"Status: {status}", - f"Branches: {plain(os.environ['PR_HEAD'])} -> {os.environ['PR_BASE']}", - os.environ["PR_URL"], - ] - ) + card = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "wrap": True, + "text": headline, + }, + { + "type": "TextBlock", + "wrap": True, + "text": f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Repository", + "value": plain(os.environ["REPOSITORY"]), + }, + { + "title": "Author", + "value": plain(os.environ["PR_AUTHOR"]), + }, + { + "title": "Status", + "value": status, + }, + { + "title": "Branches", + "value": ( + f"{plain(os.environ['PR_HEAD'])} -> " + f"{plain(os.environ['PR_BASE'])}" + ), + }, + ], + }, + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Open pull request", + "url": os.environ["PR_URL"], + } + ], + } + + payload = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": card, + } + ], + } with open( os.environ["PAYLOAD_PATH"], @@ -105,7 +154,7 @@ jobs: encoding="utf-8", ) as payload_file: json.dump( - {"text": text}, + payload, payload_file, ensure_ascii=False, ) From 9331712f9af59b560544f86f8857f6e674c915fd Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sat, 22 Aug 2026 10:51:46 +1000 Subject: [PATCH 080/247] fix(tests):Flaky test used stale info --- test/api/tasks_api_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api/tasks_api_test.rb b/test/api/tasks_api_test.rb index 016f789e7f..2683c32385 100644 --- a/test/api/tasks_api_test.rb +++ b/test/api/tasks_api_test.rb @@ -837,8 +837,6 @@ def test_require_comment_for_feedback_submission_assess_in_portfolio td1 = unit.task_definitions.first project = unit.active_projects.first - task = project.task_for_task_definition(td1) - td1.update( upload_requirements: [{ "key" => 'file0', "name" => 'Shape Class', "type" => 'code' }], target_grade: 0, # Pass @@ -847,6 +845,8 @@ def test_require_comment_for_feedback_submission_assess_in_portfolio assess_in_portfolio_only: false ) + task = project.task_for_task_definition(td1) + add_auth_header_for(user: project.user) # Use a direct submit here so the test can focus on the comment requirement. From 9708d7aeee356aa218729c18789ab288996f9fb8 Mon Sep 17 00:00:00 2001 From: Maple 'Ryan' Fox Date: Sat, 22 Aug 2026 14:59:52 +1000 Subject: [PATCH 081/247] Update condition for Teams notification workflow Simplified condition for triggering Teams notifications. --- .github/workflows/notify-teams-pr.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/notify-teams-pr.yml b/.github/workflows/notify-teams-pr.yml index 5cf0be21f6..eb1e03996c 100644 --- a/.github/workflows/notify-teams-pr.yml +++ b/.github/workflows/notify-teams-pr.yml @@ -25,10 +25,7 @@ jobs: # doubtfire-lms. Second keeps the webhook out of reach of anonymous fork PRs # while still notifying teammates who work from their own forks, which # several doubtfire-web contributors do. - if: >- - github.repository_owner == 'ontrack-features-t2-2026' && - (github.event.pull_request.head.repo.full_name == github.repository || - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)) + if: github.repository_owner == 'ontrack-features-t2-2026' runs-on: ubuntu-latest timeout-minutes: 2 From 355447a98d8f204c24a5831618cb52826b288193 Mon Sep 17 00:00:00 2001 From: Maple 'Ryan' Fox Date: Sat, 22 Aug 2026 19:32:07 +1000 Subject: [PATCH 082/247] bugfix(notif): member requirement before secret access --- .github/workflows/notify-teams-pr.yml | 135 +++++++++++++------------- 1 file changed, 68 insertions(+), 67 deletions(-) diff --git a/.github/workflows/notify-teams-pr.yml b/.github/workflows/notify-teams-pr.yml index eb1e03996c..99308ed75f 100644 --- a/.github/workflows/notify-teams-pr.yml +++ b/.github/workflows/notify-teams-pr.yml @@ -15,22 +15,72 @@ on: # # message per requested reviewer. Decide, do not default. # Removes all GITHUB_TOKEN scopes, which this workflow does not need. It does NOT -# restrict secrets.* - the webhook is protected by the job guard below, not by this. +# restrict secrets.* - the webhook is protected by the guards below, not by this. permissions: {} jobs: notify-teams: name: Post pull request notification to Teams - # First clause makes the file inert if it ever travels to thoth-tech or - # doubtfire-lms. Second keeps the webhook out of reach of anonymous fork PRs - # while still notifying teammates who work from their own forks, which - # several doubtfire-web contributors do. + # The job guard makes the file inert if it ever travels to thoth-tech or + # doubtfire-lms. The step below restricts notifications to the two OnTrack + # teams, regardless of which repository or fork the pull request comes from. if: github.repository_owner == 'ontrack-features-t2-2026' runs-on: ubuntu-latest timeout-minutes: 2 steps: + - name: Check pull request author team membership + id: team-membership + shell: bash + env: + TEAM_MEMBERSHIP_TOKEN: ${{ secrets.TEAM_MEMBERSHIP_TOKEN }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + set -euo pipefail + + if [[ -z "${TEAM_MEMBERSHIP_TOKEN:-}" ]]; then + echo "::error::The TEAM_MEMBERSHIP_TOKEN secret is not configured." + exit 1 + fi + + for team in ontrack-contributors ontrack-leads; do + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${TEAM_MEMBERSHIP_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --output "${RUNNER_TEMP}/team-membership.json" \ + --write-out '%{http_code}' \ + "https://api.github.com/orgs/ontrack-features-t2-2026/teams/${team}/memberships/${PR_AUTHOR}" \ + )" || http_status="000" + + case "${http_status}" in + 200) + membership_state="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])' \ + < "${RUNNER_TEMP}/team-membership.json")" + if [[ "${membership_state}" == "active" ]]; then + echo "eligible=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + ;; + 404) ;; + *) + echo "::error::GitHub returned HTTP ${http_status} while checking team membership." + exit 1 + ;; + esac + done + + echo "eligible=false" >> "${GITHUB_OUTPUT}" + - name: Build and send Teams notification + if: steps.team-membership.outputs.eligible == 'true' shell: bash env: TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_PR_WEBHOOK_URL }} @@ -83,67 +133,18 @@ jobs: else "Ready for review" ) - card = { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.2", - "body": [ - { - "type": "TextBlock", - "size": "Medium", - "weight": "Bolder", - "wrap": True, - "text": headline, - }, - { - "type": "TextBlock", - "wrap": True, - "text": f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", - }, - { - "type": "FactSet", - "facts": [ - { - "title": "Repository", - "value": plain(os.environ["REPOSITORY"]), - }, - { - "title": "Author", - "value": plain(os.environ["PR_AUTHOR"]), - }, - { - "title": "Status", - "value": status, - }, - { - "title": "Branches", - "value": ( - f"{plain(os.environ['PR_HEAD'])} -> " - f"{plain(os.environ['PR_BASE'])}" - ), - }, - ], - }, - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "Open pull request", - "url": os.environ["PR_URL"], - } - ], - } - - payload = { - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "contentUrl": None, - "content": card, - } - ], - } + # Teams renders a break for "\n\n" and not for a single "\n". + text = "\n\n".join( + [ + headline, + f"Repository: {os.environ['REPOSITORY']}", + f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", + f"Author: {plain(os.environ['PR_AUTHOR'])}", + f"Status: {status}", + f"Branches: {plain(os.environ['PR_HEAD'])} -> {os.environ['PR_BASE']}", + os.environ["PR_URL"], + ] + ) with open( os.environ["PAYLOAD_PATH"], @@ -151,7 +152,7 @@ jobs: encoding="utf-8", ) as payload_file: json.dump( - payload, + {"text": text}, payload_file, ensure_ascii=False, ) From 67d9f92e8f6e9c812b45a189ddee9a0a5aaf87bb Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 00:15:21 +1000 Subject: [PATCH 083/247] feat(notifications): queue notification emails with Sidekiq --- app/mailers/notifications_mailer.rb | 4 +- app/services/notification_service.rb | 35 +++----- app/sidekiq/notification_email_job.rb | 17 ++++ test/services/notification_service_test.rb | 66 ++++++++++----- test/sidekiq/notification_email_job_test.rb | 82 +++++++++++++++++++ .../send_due_soon_reminders_job_test.rb | 1 + ..._due_date_changed_notification_job_test.rb | 1 + 7 files changed, 159 insertions(+), 47 deletions(-) create mode 100644 app/sidekiq/notification_email_job.rb create mode 100644 test/sidekiq/notification_email_job_test.rb diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index eb9d9c6dd6..f83af68556 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -6,8 +6,8 @@ def add_general end # Sends a single in-system notification as an email. Called by - # NotificationService, which rescues delivery errors so the in-app - # notification is never blocked by a mail problem. + # NotificationEmailJob, which lets delivery failures reach Sidekiq so they can + # be retried without blocking the request that created the notification. def single_notification(notification) add_general diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index aa18cc5c5f..9018865a69 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -1,7 +1,7 @@ # Central entry point for raising a notification. # # Creates the in-app record and fans out to the enabled delivery channels -# (email now, push in Stage 4). A single category toggle (the user's +# (email through Sidekiq, push immediately). A single category toggle (the user's # receive_*_notifications preference) gates every channel: if the category is # off, the notification is suppressed entirely. Per-channel granularity # (a type x channel matrix) is deferred to a future iteration. @@ -34,7 +34,7 @@ def self.notify(user:, type:, event:, message:, link: nil) link: link ) - deliver_email(notification) + queue_email(notification) PushNotificationService.deliver(notification) notification @@ -48,27 +48,16 @@ def self.deliver_to?(user, type) user.public_send(pref) end - # Email channel. Best-effort: a mail failure must never block the in-app - # notification, so errors are logged and swallowed here. - # - # Sent inline, rather than with deliver_later or a Sidekiq job. - # - # No Active Job queue adapter is configured, so deliver_later would run on - # Active Job's in-process :async thread pool. That does execute, but only in - # memory: anything still pending is lost when the container restarts, and it - # shows up in no dashboard. A Sidekiq job would be worse in development, where - # the stack starts Redis but runs no worker process at all, so perform_async - # would queue to Redis and sit there forever without reporting an error. - # - # Known trade-off: this runs on the request path, and production delivers over - # SMTP (config/environments/production.rb), so a slow mail server slows down - # whatever action raised the notification. The rescue below cannot prevent that - # latency, and it also swallows the failure without retrying. Moving this onto - # a real queue is ticket EN-F03, which adds the worker service first. - def self.deliver_email(notification) - NotificationsMailer.single_notification(notification).deliver_now + # Email channel. Queue only the stable Notification id; message content, + # recipient details and other student data remain in the database. Queue + # connection errors are best-effort so the in-app record and push delivery are + # not blocked. Delivery failures are raised by the job for Sidekiq to retry. + def self.queue_email(notification) + NotificationEmailJob.perform_async(notification.id) rescue StandardError => e - Rails.logger.error "Failed to send notification email for user #{notification.user_id}: #{e.message}" + Rails.logger.error( + "Failed to queue notification email for Notification #{notification.id}: #{e.class}" + ) end - private_class_method :deliver_email + private_class_method :queue_email end diff --git a/app/sidekiq/notification_email_job.rb b/app/sidekiq/notification_email_job.rb new file mode 100644 index 0000000000..b788b0a49f --- /dev/null +++ b/app/sidekiq/notification_email_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class NotificationEmailJob + include Sidekiq::Job + + # The queue carries only the stable Notification id. Message content, + # recipient details and other student data remain in the database and are + # loaded by the worker. + sidekiq_options retry: 3 + + def perform(notification_id) + notification = Notification.find_by(id: notification_id) + return if notification.nil? + + NotificationsMailer.single_notification(notification).deliver_now + end +end diff --git a/test/services/notification_service_test.rb b/test/services/notification_service_test.rb index 5ee7e5e7a6..2511ded6c4 100644 --- a/test/services/notification_service_test.rb +++ b/test/services/notification_service_test.rb @@ -4,27 +4,35 @@ class NotificationServiceTest < ActiveSupport::TestCase setup do ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear end - def test_notify_creates_a_notification_and_sends_one_email + def test_notify_creates_a_notification_and_queues_one_id_only_email_job user = FactoryBot.create(:user) + notification = nil - notification = NotificationService.notify( - user: user, - type: 'task', - event: 'task_comment_created', - message: 'Your tutor commented on your task.', - link: "/projects/#{user.id}" - ) + assert_difference( + -> { NotificationEmailJob.jobs.size }, + 1 + ) do + notification = NotificationService.notify( + user: user, + type: 'task', + event: 'task_comment_created', + message: 'Your tutor commented on your task.', + link: "/projects/#{user.id}" + ) + end assert notification.persisted? assert_equal 'task', notification.notification_type assert_equal 'task_comment_created', notification.event - # deliver_now, so the mail is in deliveries immediately without any queue - # being drained. This is what breaks if someone puts deliver_later back. - assert_equal 1, ActionMailer::Base.deliveries.count - assert_equal [user.email], ActionMailer::Base.deliveries.last.to + job = NotificationEmailJob.jobs.last + assert_equal 'NotificationEmailJob', job['class'] + assert_equal 'default', job['queue'] + assert_equal [notification.id], job['args'] + assert_equal 0, ActionMailer::Base.deliveries.count end def test_notify_requires_an_event_keyword @@ -44,6 +52,7 @@ def test_blank_event_is_rejected end end + assert_empty NotificationEmailJob.jobs assert_equal 0, ActionMailer::Base.deliveries.count end @@ -72,7 +81,6 @@ def test_message_at_the_validated_maximum_survives_a_round_trip def test_notification_is_suppressed_when_the_category_preference_is_off user = FactoryBot.create(:user, receive_feedback_notifications: false) - assert_no_difference 'Notification.count' do result = NotificationService.notify( user: user, type: 'feedback', event: 'task_comment_created', message: 'Suppressed.' @@ -80,31 +88,45 @@ def test_notification_is_suppressed_when_the_category_preference_is_off assert_nil result end + assert_empty NotificationEmailJob.jobs assert_equal 0, ActionMailer::Base.deliveries.count end - def test_types_without_a_preference_are_always_delivered - user = FactoryBot.create(:user, receive_task_notifications: false, receive_feedback_notifications: false, receive_portfolio_notifications: false) - - notification = NotificationService.notify( - user: user, type: 'general', event: 'always_sent', message: 'General notice.' + def test_types_without_a_preference_are_always_queued + user = FactoryBot.create( + :user, + receive_task_notifications: false, + receive_feedback_notifications: false, + receive_portfolio_notifications: false ) + notification = nil + + assert_difference( + -> { NotificationEmailJob.jobs.size }, + 1 + ) do + notification = NotificationService.notify( + user: user, type: 'general', event: 'always_sent', message: 'General notice.' + ) + end assert notification.persisted? - assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [notification.id], NotificationEmailJob.jobs.last['args'] + assert_equal 0, ActionMailer::Base.deliveries.count end - def test_a_mail_failure_does_not_block_the_in_app_notification + def test_a_queue_failure_does_not_block_the_in_app_notification user = FactoryBot.create(:user) - NotificationsMailer.stub :single_notification, ->(_n) { raise StandardError, 'smtp exploded' } do + NotificationEmailJob.stub(:perform_async, ->(_id) { raise 'redis unavailable' }) do notification = NotificationService.notify( - user: user, type: 'general', event: 'mail_failure_check', message: 'Still saved.' + user: user, type: 'general', event: 'queue_failure_check', message: 'Still saved.' ) assert notification.persisted? end + assert_equal 0, NotificationEmailJob.jobs.size assert_equal 0, ActionMailer::Base.deliveries.count end end diff --git a/test/sidekiq/notification_email_job_test.rb b/test/sidekiq/notification_email_job_test.rb new file mode 100644 index 0000000000..7edb767e78 --- /dev/null +++ b/test/sidekiq/notification_email_job_test.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' + +class NotificationEmailJobTest < ActiveSupport::TestCase + setup do + ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear + end + + def test_perform_delivers_the_notification_email + notification = FactoryBot.create( + :notification, + event: 'general', + message: 'EN-F03 job delivery test.' + ) + + assert_difference( + -> { ActionMailer::Base.deliveries.count }, + 1 + ) do + NotificationEmailJob.new.perform(notification.id) + end + + mail = ActionMailer::Base.deliveries.last + body = if mail.multipart? + mail.parts.map { |part| part.body.decoded }.join("\n") + else + mail.body.decoded + end + expected_subject = "#{Doubtfire::Application.config.institution[:product_name]}: New notification" + + assert_equal [notification.user.email], mail.to + assert_equal expected_subject, mail.subject + assert_includes body, notification.message + end + + def test_missing_notification_is_a_no_op + assert_no_difference( + -> { ActionMailer::Base.deliveries.count } + ) do + NotificationEmailJob.new.perform(-1) + end + end + + def test_delivery_failure_is_raised_so_sidekiq_can_retry + notification = FactoryBot.create( + :notification, + event: 'general' + ) + failing_delivery = Class.new do + def deliver_now + raise 'smtp unavailable' + end + end.new + + NotificationsMailer.stub(:single_notification, ->(_notification) { failing_delivery }) do + error = assert_raises(RuntimeError) do + NotificationEmailJob.new.perform(notification.id) + end + assert_equal 'smtp unavailable', error.message + end + end + + def test_async_payload_contains_only_the_notification_id + notification = FactoryBot.create( + :notification, + event: 'general' + ) + jid = NotificationEmailJob.perform_async(notification.id) + job = NotificationEmailJob.jobs.find do |candidate| + candidate['jid'] == jid + end + + assert_not_nil job + assert_equal 'NotificationEmailJob', job['class'] + assert_equal 'default', job['queue'] + assert_equal [notification.id], job['args'] + assert_equal 0, ActionMailer::Base.deliveries.count + end +end diff --git a/test/sidekiq/send_due_soon_reminders_job_test.rb b/test/sidekiq/send_due_soon_reminders_job_test.rb index 09d84d0919..86f40a3c36 100644 --- a/test/sidekiq/send_due_soon_reminders_job_test.rb +++ b/test/sidekiq/send_due_soon_reminders_job_test.rb @@ -250,6 +250,7 @@ def test_the_schedule_entry_points_at_this_job def run_job SendDueSoonRemindersJob.new.perform + NotificationEmailJob.drain end def delivered_body diff --git a/test/sidekiq/task_due_date_changed_notification_job_test.rb b/test/sidekiq/task_due_date_changed_notification_job_test.rb index 23517c83db..95c0730b67 100644 --- a/test/sidekiq/task_due_date_changed_notification_job_test.rb +++ b/test/sidekiq/task_due_date_changed_notification_job_test.rb @@ -148,6 +148,7 @@ def run_job @previous_due_date, @new_due_date ) + NotificationEmailJob.drain end def eligible_projects From f17430461dafcf2edbfff6ffeefdb65f9ccee755 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 01:05:17 +1000 Subject: [PATCH 084/247] Update notification tests for queued email delivery --- test/models/notification_extension_test.rb | 5 +++++ test/models/notification_group_test.rb | 7 +++++++ test/models/notification_new_task_test.rb | 2 ++ test/models/notification_task_comment_test.rb | 5 +++++ test/models/notification_task_status_test.rb | 7 +++++++ 5 files changed, 26 insertions(+) diff --git a/test/models/notification_extension_test.rb b/test/models/notification_extension_test.rb index a1b59e8ea5..57ccd740d0 100644 --- a/test/models/notification_extension_test.rb +++ b/test/models/notification_extension_test.rb @@ -10,6 +10,7 @@ class NotificationExtensionTest < ActiveSupport::TestCase setup do ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear @project = FactoryBot.create(:project) @unit = @project.unit @@ -48,6 +49,7 @@ def test_granted_extension_notifies_student_with_new_date extension.assess_extension(@tutor, true) end end + NotificationEmailJob.drain extension.reload notification = Notification.recent_first.first @@ -90,6 +92,7 @@ def test_denied_extension_notifies_student assert_difference 'Notification.count', 1 do extension.assess_extension(@tutor, false) end + NotificationEmailJob.drain extension.reload notification = Notification.recent_first.first @@ -115,6 +118,7 @@ def test_already_assessed_extension_does_not_send_another_notification extension.assess_extension(@tutor, false) ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear assert_no_difference 'Notification.count' do result = extension.assess_extension(@tutor, true) @@ -169,6 +173,7 @@ def test_extension_notification_uses_event_specific_templates extension = create_extension_request extension.assess_extension(@tutor, false) + NotificationEmailJob.drain parts = delivered_parts diff --git a/test/models/notification_group_test.rb b/test/models/notification_group_test.rb index c95245ae20..9efd2227f8 100644 --- a/test/models/notification_group_test.rb +++ b/test/models/notification_group_test.rb @@ -8,6 +8,7 @@ class NotificationGroupTest < ActiveSupport::TestCase setup do ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear @project = FactoryBot.create(:project) @group = FactoryBot.create(:group, unit: @project.unit) @@ -25,6 +26,7 @@ def test_adding_a_member_notifies_only_that_student assert_difference 'Notification.count', 1 do @group.add_member(@project) end + NotificationEmailJob.drain notification = Notification.recent_first.first @@ -51,10 +53,12 @@ def test_removing_a_member_notifies_that_student @group.add_member(@project) ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear assert_difference 'Notification.count', 1 do @group.remove_member(@project) end + NotificationEmailJob.drain notification = Notification.recent_first.first @@ -73,10 +77,12 @@ def test_other_group_members_are_not_notified @group.add_member(other_project) ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear assert_difference 'Notification.count', 1 do @group.add_member(@project) end + NotificationEmailJob.drain notification = Notification.recent_first.first @@ -111,6 +117,7 @@ def test_switch_to_tutorial_does_not_send_leave_then_join_notifications new_tutorial = FactoryBot.create(:tutorial, unit: unit, campus: nil) ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear assert_no_difference 'Notification.count' do group.switch_to_tutorial(new_tutorial) diff --git a/test/models/notification_new_task_test.rb b/test/models/notification_new_task_test.rb index cb2fa0494f..d6e07e5340 100644 --- a/test/models/notification_new_task_test.rb +++ b/test/models/notification_new_task_test.rb @@ -9,6 +9,7 @@ class NotificationNewTaskTest < ActiveSupport::TestCase setup do ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear @unit = FactoryBot.create( :unit, @@ -53,6 +54,7 @@ class NotificationNewTaskTest < ActiveSupport::TestCase def run_job NewTaskAvailableNotificationJob.new.perform(@task_definition.id) + NotificationEmailJob.drain end def event_notifications diff --git a/test/models/notification_task_comment_test.rb b/test/models/notification_task_comment_test.rb index c39bc8091f..7329b9e6e8 100644 --- a/test/models/notification_task_comment_test.rb +++ b/test/models/notification_task_comment_test.rb @@ -7,6 +7,7 @@ class NotificationTaskCommentTest < ActiveSupport::TestCase setup do ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear @project = FactoryBot.create(:project) @unit = @project.unit @@ -30,6 +31,7 @@ def test_a_tutor_comment_notifies_the_student assert_difference 'Notification.count', 1 do @task.add_text_comment(@tutor, 'Have a look at question three.') end + NotificationEmailJob.drain notification = Notification.recent_first.first @@ -48,6 +50,7 @@ def test_a_student_comment_notifies_the_tutor assert_difference 'Notification.count', 1 do @task.add_text_comment(@student, 'I am stuck on question three.') end + NotificationEmailJob.drain notification = Notification.recent_first.first @@ -70,6 +73,7 @@ def test_no_notification_when_the_feedback_preference_is_off def test_the_comment_text_is_not_in_the_notification_or_the_email secret = 'Please do not put this sentence in an email.' @task.add_text_comment(@tutor, secret) + NotificationEmailJob.drain notification = Notification.recent_first.first body = delivered_body @@ -102,6 +106,7 @@ def test_the_link_points_at_the_task_on_the_student_dashboard def test_the_event_specific_template_is_used_instead_of_the_generic_one @task.add_text_comment(@tutor, 'Template check.') + NotificationEmailJob.drain body = delivered_body diff --git a/test/models/notification_task_status_test.rb b/test/models/notification_task_status_test.rb index 68004e3474..b9a96d7528 100644 --- a/test/models/notification_task_status_test.rb +++ b/test/models/notification_task_status_test.rb @@ -8,6 +8,7 @@ class NotificationTaskStatusTest < ActiveSupport::TestCase setup do ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear @project = FactoryBot.create(:project) @unit = @project.unit @@ -21,6 +22,7 @@ class NotificationTaskStatusTest < ActiveSupport::TestCase @task.update!(task_status: TaskStatus.ready_for_feedback) @task.add_status_comment(@student, TaskStatus.ready_for_feedback) ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear end # The notification email is multipart, and Mail::Body#to_s is empty for a @@ -37,6 +39,7 @@ def test_a_staff_status_change_notifies_the_student assert_difference 'Notification.count', 1 do assert @task.trigger_transition(trigger: 'discuss', by_user: @tutor) end + NotificationEmailJob.drain notification = Notification.recent_first.first @@ -62,6 +65,7 @@ def test_a_students_own_action_notifies_nobody def test_an_unchanged_status_notifies_nobody @task.trigger_transition(trigger: 'discuss', by_user: @tutor) ActionMailer::Base.deliveries.clear + NotificationEmailJob.clear # Re-applying the same status is a no-op: no change, no notification. assert_no_difference 'Notification.count' do @@ -83,6 +87,7 @@ def test_no_notification_when_the_task_preference_is_off def test_the_status_value_is_not_in_the_notification_or_the_email @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + NotificationEmailJob.drain notification = Notification.recent_first.first body = delivered_body @@ -115,6 +120,7 @@ def test_the_link_points_at_the_task_on_the_student_dashboard def test_the_event_specific_template_is_used_instead_of_the_generic_one @task.trigger_transition(trigger: 'discuss', by_user: @tutor) + NotificationEmailJob.drain body = delivered_body @@ -129,6 +135,7 @@ def test_bulk_marking_still_notifies_one_per_task assert_difference 'Notification.count', 1 do assert @task.trigger_transition(trigger: 'discuss', by_user: @tutor, bulk: true) end + NotificationEmailJob.drain assert_equal 1, ActionMailer::Base.deliveries.count assert_equal [@student.email], ActionMailer::Base.deliveries.last.to From 7d7ebf21a94c12299c46e98a451a8cd2fa472494 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 01:44:42 +1000 Subject: [PATCH 085/247] Stabilize extension notification test setup --- test/models/notification_extension_test.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/models/notification_extension_test.rb b/test/models/notification_extension_test.rb index 57ccd740d0..c9fac274eb 100644 --- a/test/models/notification_extension_test.rb +++ b/test/models/notification_extension_test.rb @@ -15,6 +15,7 @@ class NotificationExtensionTest < ActiveSupport::TestCase @project = FactoryBot.create(:project) @unit = @project.unit @task_definition = @unit.task_definitions.first + @task_definition.update!(due_date: @task_definition.target_date + 2.weeks) @task = @project.task_for_task_definition(@task_definition) @student = @project.student @@ -44,10 +45,8 @@ def delivered_parts def test_granted_extension_notifies_student_with_new_date extension = create_extension_request - @task.stub :can_apply_for_extension?, true do - assert_difference 'Notification.count', 1 do - extension.assess_extension(@tutor, true) - end + assert_difference 'Notification.count', 1 do + extension.assess_extension(@tutor, true) end NotificationEmailJob.drain From 82b78e35a60cef98a00cf9cae4760d028a4b1c66 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 02:05:52 +1000 Subject: [PATCH 086/247] docs(notifications): document push opt-in and permission flow --- .../push-opt-in-permission-flow.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/notifications/push-opt-in-permission-flow.md diff --git a/docs/notifications/push-opt-in-permission-flow.md b/docs/notifications/push-opt-in-permission-flow.md new file mode 100644 index 0000000000..483a623696 --- /dev/null +++ b/docs/notifications/push-opt-in-permission-flow.md @@ -0,0 +1,269 @@ +# Push opt-in and permission flow + +MN-D02. This document records the user-visible flow for enabling and disabling +Web Push on one device, the browser and server state behind it, and the recovery +path for each known failure. + +This is a documentation-only ticket. It does not change permission, subscription, +delivery, or sign-out behaviour. + +Push is device-specific. Turning it on stores this browser's subscription for +the signed-in user; it does not opt every browser or phone into push. Existing +notification category preferences still apply. For example, turning task +notifications off prevents task notifications on every channel, even when this +device remains subscribed to push. + +For server configuration, payload shape, and delivery diagnostics, read +[`push-setup.md`](./push-setup.md). For secure-context rules, service-worker +cleanup, and phone tunnel setup, read +[`testing-push-locally.md`](./testing-push-locally.md). + +## Happy path + +The browser permission prompt must follow a user action. OnTrack therefore asks +for permission only after the user clicks the push setting; it never prompts on +sign-in or page load. + +```text +Sign in + -> open Profile + -> wait for the service worker to start + -> click "Turn on push notifications on this device" + -> grant the browser's notification permission + -> browser creates a PushSubscription using the server's VAPID public key + -> web app POSTs endpoint, p256dh, and auth to /api/push_subscriptions + -> api stores or updates the subscription for the signed-in user + -> another user action raises a notification event + -> api sends the push through the stored endpoint + -> service worker displays the notification +``` + +In more detail: + +1. The user signs in and opens **Profile**. The push control is part of the + edit-profile form, below the notification category settings. +2. The Angular service worker registers about six seconds after application + bootstrap. Until it is ready, the button is disabled and the page says: + **Still starting up. This becomes available a few seconds after the page + loads.** +3. `PushNotificationService.blocker()` checks that the browser exposes the + Notifications and Push APIs, permission has not already been denied, VAPID + is configured, and `SwPush` is enabled. +4. The user clicks **Turn on push notifications on this device**. The button is + disabled while the request is running so a second click cannot create a + competing request. +5. `SwPush.requestSubscription` asks the browser for permission and supplies + the VAPID public key published by the api. +6. After permission is granted, the browser returns a subscription. The web app + posts its `endpoint`, `p256dh`, and `auth` values to the authenticated + `POST /api/push_subscriptions` endpoint. +7. The api validates the endpoint as a recognised HTTPS push service and stores + it for the current user. Posting the same endpoint again updates its keys + rather than creating a duplicate. If the endpoint previously belonged to + another account on the same browser, ownership moves to the current user. +8. The page shows **This device will receive push notifications**, the button + changes to **Turn off push notifications on this device**, and a toast says + **Push notifications turned on**. +9. When an enabled notification event is raised, + `NotificationService.notify` creates the in-app notification and calls the + push delivery service. The service sends the Angular notification payload to + every stored subscription for the recipient. `ngsw-worker.js` displays it. + +Permission and subscription are different state. Browser permission allows the +site to show notifications. The `PushSubscription` gives the api a destination +and encryption keys. A user needs both, plus an active service worker and an +enabled notification category, before an event can appear as a push. + +## What the user sees + +| State | Push control | User-visible result | Recovery | +| ------------------------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Service worker is starting | Disabled | **Still starting up. This becomes available a few seconds after the page loads.** | Wait at least six seconds. If it persists, follow the service-worker checks below. | +| Ready, permission is `default` | Enabled | **Turn on push notifications on this device** | Click the button and choose **Allow** in the browser prompt. | +| Subscribing | Disabled | The existing button remains visible while the request runs. | Wait for the success or error toast; do not reload during the prompt. | +| Subscribed | Enabled | **Turn off push notifications on this device**, plus **This device will receive push notifications.** | No action is needed. A real event is still required to prove delivery. | +| Permission denied | Disabled | **You have blocked notifications for this site**, followed by browser-specific steps for Chrome, Edge, or Firefox, or generic steps for another browser. | Allow notifications in site settings and reload. A site cannot override a denial or prompt again by itself. | +| Push API unsupported | Disabled | **This browser does not support push notifications.** | Use a browser and device that expose Web Push in a secure context, and check that platform's installation requirements. | +| VAPID not configured | Disabled | **Push notifications are not set up on this server.** | An operator must configure the api. The user cannot correct this in the browser. | +| Request fails after a denial | Disabled on the next state check | Toast: **Notifications are blocked in your browser**. | Change the site's notification permission to Allow, then reload. | +| Other subscribe or unsubscribe error | Depends on the browser subscription that remains | Toast: **Could not change push notifications**. | Check the api response and logs, then retry. Do not treat the toast as evidence that local and server state match. | + +The browser-specific denial instructions come from +`PERMISSION_DENIED_INSTRUCTIONS` in the web push service. Opera and unrecognised +browsers deliberately use the generic instructions rather than Chrome's steps. + +## Failure and recovery paths + +### Permission was denied + +`Notification.permission === 'denied'` is a hard blocker. OnTrack disables the +button because browsers do not let a site reverse that decision or show the +permission prompt again. The page explains how to reopen the site's permission +settings for Chrome, Edge, and Firefox. After changing the permission, reload so +the blocker and the local subscription are read again. + +If the user denies the first prompt, `requestSubscription` rejects. The immediate +feedback is the **Notifications are blocked in your browser** toast; after the +state refresh or reload, the disabled control and recovery steps explain what to +do next. + +### The browser or context is unsupported + +OnTrack reports **This browser does not support push notifications** when either +`Notification` or `PushManager` is absent. This can mean the browser genuinely +lacks Web Push, but it can also mean the page is outside a secure context. + +`http://localhost` is treated as trustworthy for local development. A phone +opened at a laptop's plain HTTP LAN address is not. Use HTTPS for a real device +and check: + +```js +window.isSecureContext && "serviceWorker" in navigator; +``` + +The result must be `true`. The phone and tunnel procedure is in +[`testing-push-locally.md`](./testing-push-locally.md). + +### The service worker is missing or stuck + +`SwPush.isEnabled` is false during the normal six-second registration delay and +when the current build did not generate or register `ngsw-worker.js`. Both cases +show the same temporary starting-up message. + +If the button stays disabled: + +1. Request `/ngsw-worker.js` and confirm it returns `200`, not `404`. +2. Confirm the browser shows an activated service worker for the current origin. +3. Clear stale registrations and caches using + `doubtfire-web/docs/service-worker.md` or the browser-specific steps in + [`testing-push-locally.md`](./testing-push-locally.md). +4. Reload and wait for registration before reopening the push setting. + +### The subscription expired or was invalidated + +When a push service answers `404` or `410`, the api treats the registration as +dead and deletes its `push_subscriptions` row. Delivery failures are swallowed +so the original in-app notification and email are not blocked. + +There is currently no message back to the open browser when this cleanup +happens. Its local `SwPush.subscription` can therefore still make Profile say +**This device will receive push notifications** even though the api no longer +has a destination. The symptom is a subscribed-looking device that receives no +push and has no row on the api. + +To recover today, click **Turn off push notifications on this device** and then +turn it on again. The web app still removes the local subscription when the api +delete returns `404`, so a fresh opt-in can create and store a new endpoint. +Automatic re-registration when a browser rotates its subscription belongs to +MN-C05; this flow does not claim it already happens. + +### The operating system muted the browser + +Browser permission can be `granted` while the operating system blocks the +browser's notifications, Focus or Do Not Disturb suppresses them, or the alert +style shows no banner. The web app cannot see those operating-system settings. +It continues to show **This device will receive push notifications**, and the +api can successfully send, while nothing appears on screen. + +Use a local notification to separate an operating-system problem from an +OnTrack delivery problem: + +```js +const registration = await navigator.serviceWorker.ready; +await registration.showNotification("OnTrack", { + body: "Local notification test; no api or push service involved", +}); +``` + +If this does not appear, allow notifications for the browser in the operating +system, choose a visible alert style, and turn off Focus or Do Not Disturb. If it +does appear, continue with the subscription, recipient, VAPID, and api-log checks +in [`push-setup.md`](./push-setup.md). + +### A delivery service or api call failed + +Temporary push errors are logged and the server keeps the subscription. One +failing browser never blocks delivery to the recipient's other devices and +never blocks the in-app notification or email. + +A failed opt-in API request produces **Could not change push notifications**. +Because the browser may already have created its local subscription before the +POST fails, verify both sides rather than relying on the toast: + +```js +await navigator.serviceWorker.ready.then((registration) => + registration.pushManager.getSubscription(), +); +``` + +```sh +docker exec doubtfire-api bundle exec rails runner \ + 'puts PushSubscription.all.map { |s| "#{s.user.username} #{s.endpoint[0, 60]}" }' +``` + +If the local subscription exists but the api row does not, turn push off and on +after correcting the api error. + +## Revoking push on this device + +Clicking **Turn off push notifications on this device** performs two operations +in this order: + +1. `DELETE /api/push_subscriptions?endpoint=...` removes the signed-in user's + server row while the auth token and endpoint still exist. +2. `SwPush.unsubscribe()` removes the browser subscription. + +Server deletion goes first because local unsubscribe discards the endpoint the +api needs to find the row. If the delete fails, the web app still unsubscribes +locally. The leftover server row is harmless and is removed when a later send +receives `404` or `410`. On success, the toast says **Push notifications turned +off** and the control returns to its opt-in state. + +Revoking the site's permission directly in browser settings is different from +using the OnTrack button. The page can detect that permission is now denied, but +the current code does not proactively delete the api row in response to a +permission change. Browser cleanup varies; any dead row is removed after the +push service reports it as expired or invalid. Use the OnTrack control when +possible so both sides are cleaned up deliberately. + +## Sign-out ordering + +Sign-out also removes push because a browser subscription survives an ordinary +web session. Leaving it behind on a shared device could send the previous +user's notifications after another person signs in. + +`AuthenticationService.signOut` therefore: + +1. calls `PushNotificationService.unsubscribeQuietly()` while the current + user's auth token still exists; +2. deletes the api row before unsubscribing in the browser; +3. continues deleting the server session and local auth token whether push + cleanup succeeds or fails; and +4. completes sign-out even when the service worker is disabled or the browser + refuses to unsubscribe. + +The quiet variant is deliberate: push cleanup protects a shared device, but an +outage must never trap someone in a signed-in session. When there is no active +service worker, sign-out cannot read a local endpoint and returns immediately; +any server row it could not address is left for delivery-time cleanup. + +## Source map + +| Responsibility | Source | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Push control, status text, busy state, and toasts | `doubtfire-web/src/app/common/edit-profile-form/edit-profile-form.component.ts` and `.html` | +| Blocker order, browser guidance, subscribe, and unsubscribe | `doubtfire-web/src/app/api/services/push-notification.service.ts` | +| Service-worker registration delay | `doubtfire-web/src/app/doubtfire-angular.module.ts` | +| Sign-out cleanup ordering | `doubtfire-web/src/app/api/services/authentication.service.ts` | +| Authenticated store, update, ownership move, and delete endpoints | `doubtfire-api/app/api/push_subscriptions_api.rb` | +| Endpoint validation and uniqueness | `doubtfire-api/app/models/push_subscription.rb` | +| Push fan-out, payload, expired-row cleanup, and error isolation | `doubtfire-api/app/services/push_notification_service.rb` | + +## Verification boundary + +The service and API tests cover the state decisions and request ordering, but +they cannot prove that a browser or operating system displayed a notification. +A complete manual verification must record the browser and OS versions, grant +permission through the Profile control, confirm the api row, trigger a real +event for the subscribed user, observe the notification, and test its click +destination. Use the local testing guide for that end-to-end procedure. From bf2c3613cf992017aa92ed0767047778b2ecf379 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 02:08:12 +1000 Subject: [PATCH 087/247] feat(notifications): email when tutorial enrolment changes --- app/models/project.rb | 20 +++ .../tutorial_changed.html.erb | 15 ++ .../tutorial_changed.text.erb | 11 ++ docs/notifications/events/tutorial_changed.md | 55 +++++++ test/models/notification_tutorial_test.rb | 151 ++++++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 app/views/notifications_mailer/tutorial_changed.html.erb create mode 100644 app/views/notifications_mailer/tutorial_changed.text.erb create mode 100644 docs/notifications/events/tutorial_changed.md create mode 100644 test/models/notification_tutorial_test.rb diff --git a/app/models/project.rb b/app/models/project.rb index 64dc33ed4e..e0bbbb0e78 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -174,10 +174,30 @@ def enrol_in(tutorial) else # there is an existing enrolment... tutorial_enrolment.tutorial = tutorial tutorial_enrolment.update!(tutorial_id: tutorial.id) + notify_tutorial_changed(tutorial) end tutorial_enrolment end + def notify_tutorial_changed(tutorial) + student = self.student + return if student.blank? + + NotificationService.notify( + user: student, + type: 'general', + event: 'tutorial_changed', + message: "You have been moved to tutorial #{tutorial.abbreviation} in #{unit.code}. It meets on #{tutorial.meeting_day} at #{tutorial.meeting_time}.", + link: "/projects/#{id}/dashboard" + ) + rescue StandardError => e + logger.error( + "Failed to raise tutorial_changed notification for project #{id}: #{e.message}" + ) + end + + private :notify_tutorial_changed + def enrolled_in?(tutorial) tutorial_enrolments.select { |e| e.tutorial_id == tutorial.id }.count > 0 || tutorial_enrolments.where(tutorial_id: tutorial.id).count > 0 end diff --git a/app/views/notifications_mailer/tutorial_changed.html.erb b/app/views/notifications_mailer/tutorial_changed.html.erb new file mode 100644 index 0000000000..ee4790c6c1 --- /dev/null +++ b/app/views/notifications_mailer/tutorial_changed.html.erb @@ -0,0 +1,15 @@ +

Hi <%= @user.name %>,

+ +

Your tutorial has changed.

+ +

<%= @notification.message %>

+ +

Please use the new tutorial day and time for your next class.

+ +<% if @notification.link.present? %> +

+ + Open your unit in <%= @doubtfire_product_name %> + +

+<% end %> diff --git a/app/views/notifications_mailer/tutorial_changed.text.erb b/app/views/notifications_mailer/tutorial_changed.text.erb new file mode 100644 index 0000000000..551cfe6ee2 --- /dev/null +++ b/app/views/notifications_mailer/tutorial_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +Your tutorial has changed. + +<%= @notification.message %> + +Please use the new tutorial day and time for your next class. +<% if @notification.link.present? -%> + +Open your unit in <%= @doubtfire_product_name %>: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> diff --git a/docs/notifications/events/tutorial_changed.md b/docs/notifications/events/tutorial_changed.md new file mode 100644 index 0000000000..c0647694f5 --- /dev/null +++ b/docs/notifications/events/tutorial_changed.md @@ -0,0 +1,55 @@ +# Event: tutorial_changed + +| Field | Value | +|---|---| +| Event name | `tutorial_changed` | +| Category | `general` | +| What triggers it | An existing tutorial enrolment is moved in place by `Project#enrol_in`. A first enrolment, selecting the same tutorial again, and the multiple-enrolment collapse path do not trigger it. | +| Who receives it | Only the affected project's student (`project.student`). Students in the old or new tutorial are not notified. | +| Preference that gates it | none, always sent | +| Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | +| Email body summary | Names the new tutorial and gives its meeting day and time. It deliberately omits the old tutorial and any other student's details. Templates are `app/views/notifications_mailer/tutorial_changed.text.erb` and `tutorial_changed.html.erb`. | +| Where it is raised | `app/models/project.rb`, in the existing-enrolment update branch of `Project#enrol_in`, through the private `notify_tutorial_changed` helper | + +## Recipient and trigger guards + +The notification is addressed directly to `project.student`. It is not fanned +out through either tutorial's enrolments, so one student's move creates one +notification and one email. + +`Project#enrol_in` has distinct paths for creating a first enrolment, collapsing +multiple stream enrolments into a single non-stream enrolment, and updating an +existing enrolment. Only the final path calls `notify_tutorial_changed`, after +the tutorial enrolment update succeeds. Selecting the current tutorial returns +before any of those paths and does not notify. + +## Notification fields + +- Type: `general` +- Event: `tutorial_changed` +- Recipient: `project.student` +- Message: names the new tutorial abbreviation, unit, meeting day and meeting time +- Link: `/projects/:project_id/dashboard` +- Preference: none; `general` has no entry in `Notification::PREFERENCE_FOR_TYPE` + +The previous tutorial is intentionally absent from the notification record, +email and push body. Marks, feedback, comments and other student names are not +included. + +Notification errors are logged and do not roll back a successful tutorial move. + +## How to check it by hand + +1. As a convenor, move one student from an existing tutorial to another. +2. Confirm that exactly one email is sent to that student. +3. Confirm that the email names the new tutorial and its day and time, and does + not name the old tutorial or any other student. +4. Add a student to their first tutorial and confirm that no email is sent. + +## Tests + +`test/models/notification_tutorial_test.rb` + +The tests cover the affected-student recipient, notification fields and push +link, both email formats, privacy-safe copy, first enrolment, the same-tutorial +no-op, the multiple-enrolment collapse path, and notification failure isolation. diff --git a/test/models/notification_tutorial_test.rb b/test/models/notification_tutorial_test.rb new file mode 100644 index 0000000000..1ca3632d5b --- /dev/null +++ b/test/models/notification_tutorial_test.rb @@ -0,0 +1,151 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-V04: notify only the affected student when an existing tutorial enrolment moves. +class NotificationTutorialTest < ActiveSupport::TestCase + include TestHelpers::PushNotificationHelper + + setup do + ActionMailer::Base.deliveries.clear + + @project = FactoryBot.create(:project) + @unit = @project.unit + @student = @project.student + @old_tutorial = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + abbreviation: 'OLD_TUT', + meeting_day: 'Monday', + meeting_time: '09:00' + ) + @new_tutorial = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + abbreviation: 'NEW_TUT', + meeting_day: 'Tuesday', + meeting_time: '14:30' + ) + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_moving_an_existing_enrolment_notifies_only_the_affected_student + @project.enrol_in(@old_tutorial) + other_project = FactoryBot.create(:project, unit: @unit, campus: @project.campus) + + ActionMailer::Base.deliveries.clear + + assert_difference 'Notification.count', 1 do + @project.enrol_in(@new_tutorial) + end + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_not_equal other_project.student, notification.user + assert_equal 'general', notification.notification_type + assert_equal 'tutorial_changed', notification.event + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard" + ) + end + + def test_message_and_templates_name_only_the_new_tutorial_schedule + @project.enrol_in(@old_tutorial) + + ActionMailer::Base.deliveries.clear + @project.enrol_in(@new_tutorial) + + notification = Notification.recent_first.first + body = delivered_body + + assert_not_empty body, 'guard: the email body must be readable' + + [notification.message, body].each do |content| + assert_includes content, @new_tutorial.abbreviation + assert_includes content, @new_tutorial.meeting_day + assert_includes content, @new_tutorial.meeting_time + assert_not_includes content, @old_tutorial.abbreviation + assert_not_includes content, @old_tutorial.meeting_day + assert_not_includes content, @old_tutorial.meeting_time + end + + mail = ActionMailer::Base.deliveries.last + assert mail.multipart? + assert_includes mail.parts.map(&:mime_type), 'text/plain' + assert_includes mail.parts.map(&:mime_type), 'text/html' + assert_includes body, 'Your tutorial has changed' + end + + def test_first_tutorial_enrolment_does_not_notify + assert_no_difference 'Notification.count' do + @project.enrol_in(@new_tutorial) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_selecting_the_same_tutorial_again_does_not_notify + @project.enrol_in(@old_tutorial) + + ActionMailer::Base.deliveries.clear + + assert_no_difference 'Notification.count' do + @project.enrol_in(@old_tutorial) + end + + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_collapsing_multiple_stream_enrolments_does_not_notify + stream_one = FactoryBot.create(:tutorial_stream, unit: @unit) + stream_two = FactoryBot.create(:tutorial_stream, unit: @unit) + streamed_tutorial_one = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + tutorial_stream: stream_one + ) + streamed_tutorial_two = FactoryBot.create( + :tutorial, + unit: @unit, + campus: @project.campus, + tutorial_stream: stream_two + ) + + @project.enrol_in(streamed_tutorial_one) + @project.enrol_in(streamed_tutorial_two) + assert_equal 2, @project.tutorial_enrolments.count + + ActionMailer::Base.deliveries.clear + + assert_no_difference 'Notification.count' do + @project.enrol_in(@new_tutorial) + end + + assert_equal [@new_tutorial], @project.reload.tutorial_enrolments.map(&:tutorial) + assert_equal 0, ActionMailer::Base.deliveries.count + end + + def test_notification_failure_does_not_stop_the_tutorial_move + @project.enrol_in(@old_tutorial) + + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification failed' } do + assert_nothing_raised do + @project.enrol_in(@new_tutorial) + end + end + + assert_equal @new_tutorial, @project.reload.tutorial_enrolments.first.tutorial + end +end From 174a59766a520aca95404b5ff902114719415b7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:10:25 +0000 Subject: [PATCH 088/247] chore(deps-dev): bump ruby-lsp from 0.23.13 to 0.26.9 Bumps [ruby-lsp](https://github.com/Shopify/ruby-lsp) from 0.23.13 to 0.26.9. - [Release notes](https://github.com/Shopify/ruby-lsp/releases) - [Commits](https://github.com/Shopify/ruby-lsp/compare/v0.23.13...v0.26.9) --- updated-dependencies: - dependency-name: ruby-lsp dependency-version: 0.26.9 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index da25b5b6df..a8236734f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -232,7 +232,7 @@ GEM rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - language_server-protocol (3.17.0.4) + language_server-protocol (3.17.0.6) lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -315,7 +315,7 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) - prism (1.4.0) + prism (1.9.0) psych (5.2.3) date stringio @@ -379,8 +379,9 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.9.2) + rbs (3.10.4) logger + tsort rbtree (0.4.6) rdoc (6.13.1) psych (>= 4.0.0) @@ -450,11 +451,10 @@ GEM rubocop (>= 1.72.1, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) ruby-filemagic (0.7.3) - ruby-lsp (0.23.13) + ruby-lsp (0.26.9) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) - rbs (>= 3, < 4) - sorbet-runtime (>= 0.5.10782) + rbs (>= 3, < 5) ruby-ole (1.2.13.1) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) @@ -520,7 +520,6 @@ GEM tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) yard-solargraph (~> 0.1) - sorbet-runtime (0.5.11966) sorted_set (1.0.3) rbtree set (~> 1.0) From b96fa8b86b38b5ba3f52eea21199c44e461577c2 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 02:13:22 +1000 Subject: [PATCH 089/247] docs(notifications): compare web push platform support --- .../web-push-browser-device-support.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/notifications/reviews/web-push-browser-device-support.md diff --git a/docs/notifications/reviews/web-push-browser-device-support.md b/docs/notifications/reviews/web-push-browser-device-support.md new file mode 100644 index 0000000000..7a59a6c1c9 --- /dev/null +++ b/docs/notifications/reviews/web-push-browser-device-support.md @@ -0,0 +1,118 @@ +# Web Push support by browser and device + +MN-D04. Support position checked 23 August 2026. + +## Answer first: can an iPhone receive OnTrack push? + +Yes, on iOS or iPadOS 16.4 or later, but only from a Home Screen web app. The +user must add OnTrack to the Home Screen, open that installed app instead of an +ordinary browser tab, tap OnTrack's enable control, and grant notification +permission. This applies whether Safari, Chrome, Edge, or Firefox added the web +app: Apple made Add to Home Screen available to third-party browsers, and the +installed web app runs separately from the browser that added it. + +An iPhone user who only opens OnTrack in a browser tab cannot receive its Web +Push notifications. MN-W01's installable manifest and MN-W03's visible iOS +installation instructions are therefore prerequisites, not optional polish. + +## Requirements common to every supported entry + +OnTrack delivery needs all of the following: + +1. A secure context. Production needs HTTPS; `http://localhost` is a special + development exception. A phone opening a laptop's plain HTTP LAN address is + not a secure context. +2. An active service worker and the Push and Notifications APIs. +3. A direct user action on OnTrack's enable control, followed by permission from + the browser or operating system. +4. A VAPID-backed `PushSubscription` stored by the authenticated + `/api/push_subscriptions` endpoint. +5. Permission for the browser or installed web app at operating-system level. +6. A subscription endpoint whose host passes OnTrack's server allowlist. + +Installation is not normally required for desktop or Android Web Push. It is a +hard requirement on iOS and iPadOS. + +## Browser and platform matrix + +"Supported" describes the browser platform, not a completed OnTrack device +test. MN-Q01 and MN-Q02 own observed delivery evidence. + +| Browser | Platform | Web Push | What it requires | Important catch for OnTrack | +| ------- | ---------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Chrome | Desktop | Supported | Current Chrome, secure context, service worker, user-granted notification permission | Chrome subscriptions normally use `fcm.googleapis.com`, which OnTrack accepts. The browser may need to remain allowed to run in the background for delivery after its windows close. | +| Chrome | Android | Supported | Current Chrome on Android and the common requirements above; installation is optional | Uses Google's push infrastructure. OnTrack accepts current `fcm.googleapis.com` and legacy `android.googleapis.com` endpoints. Android may separately mute Chrome or the site. | +| Chrome | iOS/iPadOS | Supported only as an installed Home Screen web app on 16.4+ | Add from Chrome's Share menu, open from the Home Screen, then enable push from a user gesture | No push from a normal Chrome tab. The installed app uses Apple's Web Push path, not Chrome/FCM. Its endpoint must match OnTrack's Apple allowlist. | +| Edge | Desktop | Supported | Current Microsoft Edge, secure context, service worker, VAPID, and user permission | Depending on platform/version, an endpoint may use Google or Microsoft infrastructure. OnTrack accepts FCM, `*.push.services.microsoft.com`, and legacy `*.notify.windows.com`. Capture the actual host in MN-Q01. | +| Edge | Android | Supported by the current Edge PWA platform; OnTrack not yet device-verified | Current Edge on Android and the common requirements; installation is optional | Microsoft documents PWA capabilities across devices but does not promise which push-service host a given mobile build returns. OnTrack will reject a new host until it is reviewed and allowlisted. | +| Edge | iOS/iPadOS | Supported only as an installed Home Screen web app on 16.4+ | Add from Edge's Share menu, open the installed app, then enable push from a user gesture | No push from an ordinary Edge tab. Apple's Home Screen web-app rules and endpoint compatibility apply. | +| Firefox | Desktop | Supported | Current Firefox, secure context, service worker, and user-granted permission | Firefox uses Mozilla's push service. OnTrack accepts `updates.push.services.mozilla.com`. Firefox must be running for desktop delivery according to Mozilla's user documentation. | +| Firefox | Android | Supported | Current Firefox for Android, site notification permission, Android notification permission, and the common requirements | Mozilla routes Firefox Android Web Push through its service plus Google Cloud Messaging. The subscription endpoint still needs to be one OnTrack accepts; record it during device testing. | +| Firefox | iOS/iPadOS | Supported only through an installed Home Screen web app on 16.4+ | Add from the Share menu, open the installed web app, then enable push from a user gesture | Do not treat Firefox's ordinary iOS tab as the receiver. Once installed, the Home Screen web app is a separate WebKit app and uses Apple's Web Push path. | +| Safari | Desktop | Supported on macOS Ventura with Safari 16.1 or later | Secure context, service worker, standards-based Web Push/VAPID, and user permission | No Apple Developer Program membership is required. Apple's guidance says senders should permit `*.push.apple.com`; OnTrack currently accepts only `web.push.apple.com`, so a future Apple endpoint on another subdomain would be rejected. | +| Safari | Android | Not available | Safari is not released for Android | Use Chrome, Edge, or Firefox and verify the endpoint host. | +| Safari | iOS/iPadOS | Supported only as an installed Home Screen web app on 16.4+ | Share → Add to Home Screen, open the installed app, tap OnTrack's enable control, then grant permission | This is the key platform limitation. No installed app means no Push API permission prompt and no delivery. Focus, Lock Screen, and per-app notification settings can still suppress display. | + +## OnTrack's endpoint allowlist is a second compatibility gate + +Browser support alone is not enough. `PushSubscription` rejects an endpoint +unless its HTTPS host is one of these exact hosts: + +- `fcm.googleapis.com` — Chrome and Chromium-family delivery. +- `android.googleapis.com` — older Chrome on Android. +- `updates.push.services.mozilla.com` — Firefox. +- `web.push.apple.com` — current Safari and iOS/iPadOS Web Push. + +It also accepts subdomains ending in: + +- `.notify.windows.com` — legacy Windows Notification Service endpoints. +- `.push.services.microsoft.com` — current Microsoft push-service endpoints. + +The suffix check includes the leading dot, so a lookalike such as +`evil-notify.windows.com` does not pass. Delivery repeats the check for rows +created before the model validation existed. + +This is deliberately stricter than "the browser implements Push API". A new +browser version can support Web Push and still receive HTTP 400 from OnTrack if +its vendor starts returning a host outside this list. Record the endpoint host +in every manual browser/device test. Review a new host against vendor +documentation before adding it; never broaden the validation just to make a +test pass. + +The Apple difference deserves monitoring: WebKit tells server operators to +allow `*.push.apple.com`, while OnTrack currently permits the single known host +`web.push.apple.com`. That is compatible with the endpoint observed when the +allowlist was written, but it is not equivalent to Apple's whole documented +namespace. + +## What this table does not claim + +- `.browserslistrc` says which JavaScript targets Angular compiles for. It is + not evidence that a browser/OS can subscribe, receive, display, and navigate + from a push. +- API presence or a successful subscription is not delivery evidence. The + operating system can mute an otherwise valid subscription. +- iOS browser branding is not a way around the Home Screen rule. +- An Android emulator is useful for layout and permission-flow checks, but does + not satisfy MN-Q02's requirement for a real-phone Lock Screen delivery. + +## Primary sources + +- Apple WebKit, [Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) — iOS/iPadOS 16.4, Home Screen requirement, direct user interaction, Lock Screen delivery, Apple push service, and third-party Add to Home Screen. +- Apple WebKit, [Meet Web Push](https://webkit.org/blog/12945/meet-web-push/) — standards-based Web Push in Safari on macOS Ventura and no Apple Developer Program requirement. +- Apple WebKit, [WebKit Features in Safari 18.4](https://webkit.org/blog/16574/webkit-features-in-safari-18-4/) — confirms standard Web Push shipped in Safari 16.1 on macOS and iOS/iPadOS 16.4. +- Google web.dev, [Push notifications overview](https://web.dev/articles/push-notifications-overview) — permission, subscription, service-worker, browser push-service, and FCM endpoint flow. +- Microsoft Edge, [Re-engage users with push messages](https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps/how-to/push) — Edge permission, Push API, VAPID, user-visible requirement, and service-worker delivery. +- Microsoft Edge, [Use Progressive Web Apps in Microsoft Edge](https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps/ux) — current device-wide PWA capability position. +- Mozilla Support, [Web Push notifications in Firefox](https://support.mozilla.org/en-US/kb/push-notifications-firefox) — Firefox desktop delivery, Mozilla push service, permissions, and Android routing. +- Mozilla Support, [Manage notifications in Firefox for Android](https://support.mozilla.org/en-US/kb/manage-notifications-firefox-android) — Android site-notification permission control. +- Mozilla Source Docs, [Push](https://firefox-source-docs.mozilla.org/dom/push/) — Firefox and Firefox for Android implementation paths. +- MDN, [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) and [Secure contexts](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) — platform API and secure-context baseline. + +## Follow-up verification + +Use `testing-push-locally.md`, then record for each tested row: operating system, +browser version, secure-context result, permission state, returned endpoint host, +API row, real event, displayed title/body, click destination, and cleanup. A +failure should become its own bug with reproduction steps rather than being +hidden in this comparison. From 8987fc74d2e996f7073f00d31da0f67f2d81f102 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 02:11:28 +1000 Subject: [PATCH 090/247] feat(notifications): email on portfolio submission receipt --- app/api/projects_api.rb | 46 ++++- .../portfolio_received.html.erb | 21 +++ .../portfolio_received.text.erb | 14 ++ .../events/portfolio_received.md | 87 +++++++++ test/models/notification_portfolio_test.rb | 178 ++++++++++++++++++ 5 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 app/views/notifications_mailer/portfolio_received.html.erb create mode 100644 app/views/notifications_mailer/portfolio_received.text.erb create mode 100644 docs/notifications/events/portfolio_received.md create mode 100644 test/models/notification_portfolio_test.rb diff --git a/app/api/projects_api.rb b/app/api/projects_api.rb index a895007ff3..60670adfa1 100644 --- a/app/api/projects_api.rb +++ b/app/api/projects_api.rb @@ -5,6 +5,27 @@ class ProjectsApi < Grape::API helpers AuthorisationHelpers helpers DbHelpers + helpers do + def notify_portfolio_received(project) + timezone = project.campus&.timezone.presence || Time.zone.name + received_at = project.portfolio_submission_date.in_time_zone(timezone) + submitted_at = received_at.strftime('%-d %B %Y at %-I:%M %p %Z (UTC%:z)') + product_name = Doubtfire::Application.config.institution[:product_name] + + NotificationService.notify( + user: project.student, + type: 'portfolio', + event: 'portfolio_received', + message: "#{product_name} received your portfolio submission at #{submitted_at}.", + link: "/projects/#{project.id}/dashboard" + ) + rescue StandardError => e + Rails.logger.error( + "Failed to raise portfolio_received notification for project #{project.id}: #{e.message}" + ) + end + end + before do authenticated? end @@ -147,11 +168,26 @@ class ProjectsApi < Grape::API error!({ error: "You do not have permissions to change this student" }, 403) end - # if someone changes this setting manually, clear the autogenerated status - project.portfolio_auto_generated = false - project.compile_portfolio = params[:compile_portfolio] - project.portfolio_submission_date = Time.zone.now - project.save + new_portfolio_submission = false + submission_saved = false + + # Lock the project while deciding whether this is new so concurrent + # retries cannot both observe the old state and send two receipts. + project.with_lock do + # A true request starts a new manual submission when no manual portfolio + # is already queued. Converting a queued auto-generated portfolio into + # a manual submission is also new; retrying the same request is not. + new_portfolio_submission = params[:compile_portfolio] && + (!project.compile_portfolio? || project.portfolio_auto_generated?) + + # if someone changes this setting manually, clear the autogenerated status + project.portfolio_auto_generated = false + project.compile_portfolio = params[:compile_portfolio] + project.portfolio_submission_date = Time.zone.now if new_portfolio_submission + submission_saved = project.save + end + + notify_portfolio_received(project) if submission_saved && new_portfolio_submission end Entities::ProjectEntity.represent(project, only: [:campus_id, :enrolled, :target_grade, :submitted_grade, :compile_portfolio, :portfolio_available, :uses_draft_learning_summary, :stats], for_student: for_student) diff --git a/app/views/notifications_mailer/portfolio_received.html.erb b/app/views/notifications_mailer/portfolio_received.html.erb new file mode 100644 index 0000000000..18510bd165 --- /dev/null +++ b/app/views/notifications_mailer/portfolio_received.html.erb @@ -0,0 +1,21 @@ +

Hi <%= @user.first_name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

+ + View the current status in <%= @doubtfire_product_name %> + +

+<% end %> + +

+ This receipt confirms when the submission was received. It does not confirm + an assessment outcome. +

+ +

+ You can manage your notification preferences in + your profile. +

diff --git a/app/views/notifications_mailer/portfolio_received.text.erb b/app/views/notifications_mailer/portfolio_received.text.erb new file mode 100644 index 0000000000..666718aedd --- /dev/null +++ b/app/views/notifications_mailer/portfolio_received.text.erb @@ -0,0 +1,14 @@ +Hi <%= @user.first_name %>, + +<%= @notification.message %> + +Open <%= @doubtfire_product_name %> to view its current status. +<% if @notification.link.present? -%> + +View the current status: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +This receipt confirms when the submission was received. It does not confirm an assessment outcome. + +You can manage your notification preferences here: +<%= @unsubscribe_url %> diff --git a/docs/notifications/events/portfolio_received.md b/docs/notifications/events/portfolio_received.md new file mode 100644 index 0000000000..dd50f21012 --- /dev/null +++ b/docs/notifications/events/portfolio_received.md @@ -0,0 +1,87 @@ +# Event: portfolio_received + +| Field | Value | +|---|---| +| Event name | `portfolio_received` | +| Category | `portfolio` | +| What triggers it | A student successfully starts a new manual portfolio submission through `PUT /projects/:id` with `compile_portfolio: true`. A repeated request while the same manual submission is already pending is not a new submission. | +| Who receives it | The submitting student (`project.student`). | +| Preference that gates it | `receive_portfolio_notifications` | +| Email subject | `#{product name}: New notification`, using the existing `NotificationsMailer#single_notification` subject | +| Email body summary | Confirms the date and time that the portfolio submission was received, including its timezone and UTC offset, and links to the project's current status. It says explicitly that the receipt does not confirm an assessment outcome. No portfolio contents, marks, grades, feedback, or other student information are included. Templates are `app/views/notifications_mailer/portfolio_received.text.erb` and `portfolio_received.html.erb`. | +| Where it is raised | `app/api/projects_api.rb`, in the `PUT /projects/:id` `compile_portfolio` branch, after the new submission state and `portfolio_submission_date` have been saved. | + +## Existing portfolio emails are different events + +The existing email audit records `PortfolioEvidenceMailer#portfolio_ready` and +`PortfolioEvidenceMailer#portfolio_failed`. Those messages are raised later by +`submission:generate_pdfs` after portfolio generation succeeds or fails. + +`portfolio_received` is the earlier receipt for accepting the student's +submission. It uses the shared `NotificationService` email path and does not +call either legacy portfolio mailer. One accepted submission therefore sends +one receipt, while a later generation result remains a separate event. + +## New-submission guard + +A receipt is raised only when a new manual submission is accepted: + +- `compile_portfolio` changes from false to true; or +- a pending auto-generated portfolio is replaced by the student's manual + submission. + +Retrying `compile_portfolio: true` while the same manual submission is already +pending does not create another notification and does not replace the original +`portfolio_submission_date`. Setting `compile_portfolio: false` does not send a +receipt. Once generation has finished and the flag is false again, a later +manual resubmission is new and receives its own receipt. + +The decision and save happen while holding a row lock on the project, so two +concurrent retries cannot both observe the submission as new. + +## Receipt time + +The saved `project.portfolio_submission_date` is the source of truth. It is +rendered in the project's campus timezone. When the project has no campus +timezone, the application timezone is used. The email includes the local date, +time, timezone abbreviation and numeric UTC offset, for example: + +`23 August 2026 at 10:34 PM AEST (UTC+10:00)` + +This is receipt metadata only. The notification and email never include the +portfolio, an assessment result, marks, grades, feedback or rationale. + +## Implementation + +The event uses: + +- `type: 'portfolio'` +- `event: 'portfolio_received'` +- recipient: `project.student` +- `link: "/projects/#{project.id}/dashboard"` + +`NotificationService` applies `receive_portfolio_notifications` before it +creates the in-app notification or delivers email and push. A failure while +raising the notification is logged without rejecting the already accepted +portfolio submission. + +## How to check it by hand + +1. Sign in as a student and submit a portfolio. +2. Confirm that exactly one receipt appears in Mailpit and that it is addressed + to the submitting student. +3. Confirm that the receipt contains the saved date, time, timezone and UTC + offset, and contains no portfolio or assessment content. +4. Repeat the same request while generation is pending and confirm that no + second receipt appears. +5. Turn off portfolio notifications, submit again after generation has + completed, and confirm that no notification or email is created. + +## Tests + +`test/models/notification_portfolio_test.rb` + +The focused tests cover the recipient, event/type, generic subject, push link, +event-specific copy, timestamp and privacy boundary, portfolio preference, +duplicate-request guard, later resubmission, manual replacement of an +auto-generated portfolio, cancellation, and notification failure isolation. diff --git a/test/models/notification_portfolio_test.rb b/test/models/notification_portfolio_test.rb new file mode 100644 index 0000000000..3514a5e40b --- /dev/null +++ b/test/models/notification_portfolio_test.rb @@ -0,0 +1,178 @@ +require 'test_helper' +require 'minitest/mock' + +# EN-V07: a newly accepted manual portfolio submission sends one receipt to +# the submitting student. +class NotificationPortfolioTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + include TestHelpers::PushNotificationHelper + + def app + Rails.application + end + + setup do + ActionMailer::Base.deliveries.clear + + @project = FactoryBot.create(:project) + @project.campus.update!(timezone: 'Australia/Melbourne') + @student = @project.student + + add_auth_header_for(user: @student) + end + + def submit_portfolio(value: true) + # Some receipt assertions freeze time. Mint the request token inside that + # clock so the authentication expiry is evaluated against the same instant. + add_auth_header_for(user: @student) + + put_json( + "/api/projects/#{@project.id}", + id: @project.id, + compile_portfolio: value + ) + + assert_equal 200, last_response.status, last_response.body + end + + def delivered_body + mail = ActionMailer::Base.deliveries.last + return '' if mail.nil? + + mail.multipart? ? mail.parts.map { |part| part.body.decoded }.join("\n") : mail.body.decoded + end + + def test_a_new_portfolio_submission_sends_one_receipt_to_the_student + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + assert_difference 'Notification.count', 1 do + submit_portfolio + end + end + + notification = Notification.recent_first.first + + assert_equal @student, notification.user + assert_equal 'portfolio', notification.notification_type + assert_equal 'portfolio_received', notification.event + assert_equal( + "#{Doubtfire::Application.config.institution[:product_name]} received your " \ + 'portfolio submission at 23 August 2026 at 10:34 PM AEST (UTC+10:00).', + notification.message + ) + assert_valid_push_payload( + notification, + expected_link: "/projects/#{@project.id}/dashboard" + ) + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_equal [@student.email], ActionMailer::Base.deliveries.last.to + assert_equal( + "#{Doubtfire::Application.config.institution[:product_name]}: New notification", + ActionMailer::Base.deliveries.last.subject + ) + end + + def test_the_receipt_uses_the_event_template_and_excludes_assessment_content + assessment_content = 'PRIVATE-ASSESSMENT-RATIONALE-7429' + @project.update!(grade_rationale: assessment_content, submitted_grade: 3) + + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + submit_portfolio + end + + body = delivered_body + + assert_not_empty body, 'guard: the body must be readable or the privacy assertions prove nothing' + assert_includes body, '23 August 2026 at 10:34 PM AEST (UTC+10:00)' + assert_includes body, 'This receipt confirms when the submission was received' + assert_includes body, 'It does not confirm an assessment outcome' + assert_not_includes body, assessment_content + end + + def test_portfolio_preference_suppresses_every_notification_channel + @student.update!(receive_portfolio_notifications: false) + + assert_no_difference 'Notification.count' do + submit_portfolio + end + + assert_empty ActionMailer::Base.deliveries + assert @project.reload.compile_portfolio? + assert_not_nil @project.portfolio_submission_date + end + + def test_retrying_a_pending_manual_submission_does_not_send_a_second_receipt + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + submit_portfolio + end + + original_submission_date = @project.reload.portfolio_submission_date + ActionMailer::Base.deliveries.clear + + travel_to Time.zone.parse('2026-08-23 12:39:00 UTC') do + assert_no_difference 'Notification.count' do + submit_portfolio + end + end + + assert_empty ActionMailer::Base.deliveries + assert_equal original_submission_date, @project.reload.portfolio_submission_date + end + + def test_a_later_resubmission_receives_a_new_receipt + travel_to Time.zone.parse('2026-08-23 12:34:00 UTC') do + submit_portfolio + end + + first_submission_date = @project.reload.portfolio_submission_date + @project.update!(compile_portfolio: false) + ActionMailer::Base.deliveries.clear + + travel_to Time.zone.parse('2026-08-24 01:15:00 UTC') do + assert_difference 'Notification.count', 1 do + submit_portfolio + end + end + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_operator @project.reload.portfolio_submission_date, :>, first_submission_date + end + + def test_a_manual_submission_replaces_a_pending_auto_generated_portfolio + @project.update!( + compile_portfolio: true, + portfolio_auto_generated: true, + portfolio_submission_date: nil + ) + + assert_difference 'Notification.count', 1 do + submit_portfolio + end + + assert_equal 1, ActionMailer::Base.deliveries.count + assert_not @project.reload.portfolio_auto_generated? + assert_not_nil @project.portfolio_submission_date + end + + def test_cancelling_portfolio_generation_does_not_send_a_receipt + assert_no_difference 'Notification.count' do + submit_portfolio(value: false) + end + + assert_empty ActionMailer::Base.deliveries + assert_nil @project.reload.portfolio_submission_date + end + + def test_a_notification_failure_does_not_reject_the_submission + NotificationService.stub :notify, ->(**_kwargs) { raise StandardError, 'notification failed' } do + assert_nothing_raised do + submit_portfolio + end + end + + assert @project.reload.compile_portfolio? + assert_not_nil @project.portfolio_submission_date + end +end From 6c3f982e8dc47740e3d9ef85f7fbe6b34e233446 Mon Sep 17 00:00:00 2001 From: maplefoxgit Date: Sun, 23 Aug 2026 02:15:51 +1000 Subject: [PATCH 091/247] docs(notifications): review v2 push lock-screen risk --- .../reviews/v2-push-lock-screen-risk.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/notifications/reviews/v2-push-lock-screen-risk.md diff --git a/docs/notifications/reviews/v2-push-lock-screen-risk.md b/docs/notifications/reviews/v2-push-lock-screen-risk.md new file mode 100644 index 0000000000..7aad41ee42 --- /dev/null +++ b/docs/notifications/reviews/v2-push-lock-screen-risk.md @@ -0,0 +1,70 @@ +# MN-S04 – v2 Push Payload Lock-Screen Risk Review + +## Decision + +**Sign-off status: NOT APPROVED.** + +The v2 event set cannot be approved against the lock-screen rule until the +failed events are changed and the conditional events have an explicit privacy +decision or safer push copy: + +- Pass: EN-V01, EN-V02, EN-V03 and EN-V08 +- Conditional: EN-V04 and EN-V07 +- Fail: EN-V05 and EN-V06 + +This review covers the current implementations of EN-V01 to EN-V03 and EN-V05 +on `feature/notifications`, plus the candidate EN-V04, EN-V06, EN-V07 and +EN-V08 ticket branches. Those candidate branches had not yet merged when this +review was completed. + +## Lock-screen rule + +A push notification must be safe to display on a locked device where anyone +nearby may read it. The banner title is **OnTrack**, and the notification body +is copied directly from `Notification#message`. Truncating that body to 400 +characters limits payload size; it does not redact sensitive content. + +The payload also contains an event-and-route collapse tag, the notification ID +and a validated internal click route. Those fields are not rendered as +lock-screen text by the service worker, so this review concentrates on the +title and body. A safe click route does not make an unsafe body safe. + +## Event findings + +| Event | Current lock-screen body | Finding | Reason and required action | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| EN-V01 – Task due date changed | `The due date for in has changed.` | **Pass** | Contains bounded academic identifiers but no date, student name, result, feedback or free-form text. | +| EN-V02 – New task available | `A new task is available: in .` | **Pass** | Contains bounded academic identifiers and does not reveal a result, submission, feedback or personal detail. | +| EN-V03 – Task due soon | ` in is due soon.` | **Pass** | Contains bounded academic identifiers without exposing the exact deadline or student-specific progress. | +| EN-V04 – Tutorial changed | `You have been moved to tutorial in . It meets on at