Skip to content

feat(notifications): land notification links, Unit Hub alerts and the digest email - #178

Merged
Clupai8o0 merged 30 commits into
11.0.xfrom
land/notification-links
Sep 27, 2026
Merged

Clupai8o0 merged 30 commits into
11.0.xfrom
land/notification-links

Conversation

@Clupai8o0

@Clupai8o0 Clupai8o0 commented Sep 27, 2026 •

Copy link
Copy Markdown

What this is

This lands two api branches that never got a PR: api/notification-links (12 commits) and ui/email-styling (10 commits). The web side is on integration/t2-2026 and is being landed next, so the notification deep links, the Unit Hub alerts and the digest setting need this first.

What is in it

  • Every notification now sends the ids of the page it is about: unit, project, student, task definition, task, comment, group, announcement and session. They sit next to the old link and are null when the record is gone.
  • A Unit Hub notification category with its own in-app, email and push opt-ins, plus a session reminder opt-in.
  • A unit gets notified when a Unit Hub announcement is published or updated, and when a session changes or is about to start.
  • Tutors are notified when a student submits a portfolio.
  • Automated comments are marked automated, so they can be shown apart from a tutor's comment.
  • A student can choose how often the summary email comes: off, daily, weekly or monthly (default weekly). It now runs on a schedule, covers all their units, and the wording changes with the cadence.
  • Staff and student emails share one HTML layout, with the task status colours, a category icon, and a button to the same page the bell opens. The layout is Outlook-safe and the text meets 4.5:1.
  • PUT /users/:id returns 422 when someone who signs in through their institution tries to change their first or last name.
  • The sidekiq schedule now removes a job that was taken out of schedule.yml.

How it was built

  • Merged api/notification-links in b8cc5dc8, then ui/email-styling in 3e4d19a4. They share one commit, which comes in once.
  • Where 11.0.x already did the same thing I kept its version: address_with_name, the notifiable column, the SUBJECTS table and the email delivery states. The per-channel opt-in is added on top, so an email the user has not opted into is marked suppressed rather than left pending.
  • Five small commits after the merges:
    • the scheduled job count in the test (12 to 15)
    • the extension email line its test expects
    • category icons and subjects for the newer emails and events, with a test for each
    • eight RuboCop offences
  • After review, one more commit (11e800b4): staff with update_user could change another user's Unit Hub opt-ins and digest frequency. Those fields are now ignored unless you are updating yourself, the same as theme_preference.

Migrations

  • 20260927000001 adds the four Unit Hub preference columns to users.
  • 20260927000002 adds digest_frequency.
  • They were 20260915000000 and 20260916000000 on the branch. I renumbered them so they sort after the newest 11.0.x migration (20260922010000). Neither has run in production.
  • Schema version 2026_09_27_000002. I ran db:populate on a separate test database and git diff --exit-code -- db/schema.rb came back clean.
  • The local demo database may have run the old numbers already, so it needs a reset before it gets this branch.

Built against

  • api: land/notification-links @ 11e800b4, on 11.0.x @ 5de649d7
  • web: 11.0.x @ 192078b93 (the web half follows in its own PR)
  • deploy: 11.0.x @ 1ca0413

Testing

  • RuboCop: 0 offences on the whole repo.
  • test/api/users_test.rb: 42 runs, 0 failures, with a new test for the staff update.
  • ruby -c on every changed file.
  • In a separate test database: the notification model and mailer tests pass (227 tests, 0 failures). The only errors in the wider runs were task and portfolio PDF tests, which need a LaTeX service my local container does not have. CI runs the full suite.

Known open items

  • Comments that start with a lowercase "Automated comment:" (task.rb:1559, accept_overseer_job.rb:111) are not flagged as automated yet. That was already the case on the branch.
  • SendDigestEmailsJob still has an old comment saying the mailer lives on another branch.

Clupai8o0 and others added 29 commits September 14, 2026 18:28
Table-based 600px card layout with inline styles, a text wordmark header,
preheader text, a bulletproof call-to-action button and a muted footer.
Applied to the notifications, additional notification email,
communications and portfolio evidence mailers. Copy, variables, links
and conditionals are unchanged.
The list endpoint now adds unit_id, project_id, student_id,
task_definition_id, task_definition_abbr, task_id, comment_id and group_id
beside the existing fields. They are worked out from the notifiable and then
the link, so older rows get them too, and a deleted record comes back as nil.
Group changes and discussion prompts now record what they are about.
Every tutor the student is enrolled with hears about a new manual portfolio
submission, or the main convenor when there is none. Gated by the tutor's
portfolio preference, deduplicated per submission, with a lock screen body
that leaves the student's name out. Also covers resubmission after Fix and
Resubmit notifying the tutor again.
…e page the bell opens

Notification#web_path makes the same choice as the web client, so a tutor's
email button opens the task inbox with that student's task selected, or the
staff portfolio view, and a student's opens their own task or comments.
Need Help, extension request, submission and comment emails use it, and the
new portfolio submitted email uses the shared layout and partials.
…ush opt-ins

Four user columns: receive_unit_hub_notifications (in-app, on by default),
receive_unit_hub_email_notifications and receive_unit_hub_push_notifications
(off), and receive_unit_hub_session_reminders (off). The category column still
switches every channel off. Email and push jobs are only queued, and only
deliver, when their own column is on too. The users API reads and writes all
four, and notifications about an announcement or session send unit_id with
announcement_id or session_id.
…sion changes

unit_announcement_published goes out when an announcement becomes visible,
including at a future publication time, or is pinned. unit_announcement_updated
goes out for an edit that changes the words or a number, at most once per
announcement per person in 30 minutes. unit_session_changed covers a new time,
place or link and has its own copy for a cancellation. unit_session_starting_soon
reminds people who opted in 30 minutes before each occurrence, swept every five
minutes.

Recipients are enrolled students and teaching staff of the active unit, never
the author. Jobs run after commit, walk recipients in batches and use a dedupe
key per announcement or session version, so a retry sends nothing twice. Each
event has an email on the shared layout, a generic lock screen line, and a push
click that opens /unit-hub on the announcement or session.
Under SAML, AAF or LDAP the institution asserts first and last name, but
the name is only written into the account when it is first created, so a
self-service rename here drifts permanently from the institution record
and is never restored on a later sign-in. Reject a differing first_name
or last_name with a 422 whenever the deployment is not database auth,
the same way the sign-in email is already handled. Preferred name stays
editable for everyone and local database-auth accounts are unchanged.
One map in NotificationMailHelper holds a glyph and an accent colour per
category, so a template names a category and the badge, the rule above
the card and the callout inside it all agree. Green means something
arrived or completed, amber a deadline is close, red something failed,
brand blue everything neutral. A template that names nothing still
renders, in brand blue.

The badge is a coloured round table cell carrying a text glyph. No img,
so it does not wait on a client that blocks remote images, and no SVG or
icon font for Outlook to drop. It is decorative: the heading beside it
always carries the meaning. Outlook draws a square where it ignores the
radius, which still reads as a badge.

New partials alongside it: facts for short labelled rows, stats for a
week of numbers with the figure first, task_rows for the task list four
messages were each rendering by hand, and note for small print under the
action. Details now takes an optional category and label.
All 24 HTML templates now name a category, so each one carries a badge
and an accent rule that say what it is about before a word is read:
submission, feedback, due soon, overdue or failed, announcement,
session, comment, portfolio, extension, group, verification, summary.

Restructured where the message was dense rather than just decorated.
The four task list emails share the new task_rows partial, which puts
the task code in its own column instead of running it into the name, and
each now ends on a button to the dashboard. The caveats about what is
not included in the email move below the button as small print, so the
callout and the action lead. The tutor note email drops its own
standalone document, which still carried a logo as a background image
and its own stylesheet, and joins the shared layout with the note in a
callout and the task and student as labelled rows.

Both weekly summaries led with seven bullets of prose where every line
began with words and the numbers were buried mid sentence. They now open
on the one fact that is still outstanding, tasks waiting for feedback
for staff and overdue tasks for students, and put the rest in rows with
the figure first. The student summary links each task it names.

Fixes the feedback ready template reading @hasComments, which was never
set, so the "and comments" wording never appeared in the HTML part even
though the text part had it right.

Plain text parts follow the same order and carry the same labels.
The accent was brand blue on nearly every message, so it carried almost
no information. The fifteen task status colours from the web app's
src/styles/tokens/_light.scss are now the source: a status looks the
same in the inbox as it does on the task. Nothing here is a new shade.
Tints are derived from the accent rather than listed, so there is no
second palette to drift.

A message that announces a status takes that status. Task submitted and
task PDFs ready are ready-for-feedback blue, submission received and
portfolio ready are complete green, the three failure messages are fail
red, due soon is the working-on-it amber, discussion is discuss teal.
Task status changed reads the task it points at and carries whatever
status that task is in, across all fifteen.

Split the categories that were doing too much work. Announcements now
carry a flag and sessions a clock, where both used to be a star; a
reschedule has its own mark apart from a new thing; a new task and a
generic notice are no longer the same. Eighteen categories where there
were twelve.

Every glyph was rendered on a coloured disc before it was picked, which
is how the clock and hourglass candidates were dropped: both default to
emoji presentation and arrive as a colour picture sitting on a coloured
circle. The quote mark is gone, it read as a stray pair of ticks rather
than a mark; comments now carry three dots. Also new: a progress bar and
a bar chart, both nested tables with a bgcolor on each cell, which is
the only filled shape Outlook will draw.
The "your week" block listed comments posted, comments received, changes
to your tasks, and two unit-wide totals. A student cannot act on any of
them and two were not even about them.

What replaces it comes from what the mailer already loads, plus two
cheap reads and one grouped count, so there is no query per task:

  - the next deadline by name and date, and how many days late or away
  - every listed task with its own due date and its own status colour
  - how much work has come back from the tutor and is waiting on the
    student to resubmit, redo or talk through
  - progress against the target grade, drawn as a bar
  - pace: how much of that grade was meant to be done by today against
    how much is, as two bars on one scale
  - every task by status, as a bar chart in the status colours
  - whose move each outstanding task is, tutor or student
  - weeks left in the unit, and the portfolio deadline when it is close

Comments posted survives only at nought, where it is a prompt to ask a
question rather than a scoreboard.

Two accuracy fixes while in here. top_tasks returns at most five rows in
total, so the overdue figure it carries is a floor, not a count; the lead
names the oldest overdue task instead of claiming a total, and says so
when the list is truncated. And nothing claims the student is caught up
unless the counts below agree, so the email cannot contradict its own
charts a screen later.

The staff summary counted its unit-wide figure over text comments only
while counting a tutor's own over seven content types, and labelled both
"comments". The labels now say which is which. The queries are unchanged.
Word ignores max-width, so a maximised Outlook window stretched the card
to the full screen and every percentage width inside it went with it. A
ghost table, real and fixed at 600 and visible only to Word, pins it
back. Three more Word problems in the same pass: the button put its
padding on an inline-block anchor, which Word drops, so it moves to the
td that already carries the fill; the bar and progress cells set a height
and a line height without mso-line-height-rule:exactly; and the task code
pill was a span with padding, which Word also drops, so it becomes a one
cell table in a partial both task lists share.

Contrast. The uppercase callout labels drew the accent on a wash of
itself and measured 4.06 to 4.19 against the 4.5 that 12px bold needs.
The helper now returns a darkened variant of the same hue, mixed towards
black rather than listed as a second set of hexes, which puts every one
above 7.2. The footer line was #9ca3af on white at 2.54 and joins the
#6b7280 the rest of the footer already uses, at 4.83.

Charts print "10 of 16" rather than "10", so the proportion is never only
in the bar. That covers a screen reader, Word where the fill may not
draw, and the phone, where the bars now hide under the existing media
query: a fixed label column and a fixed value column left them ten pixels
to draw in at 320, and they only ever repeated the number beside them.
The task code column narrows there too, and task links get a 44px target.
…link

The staff summary had exactly one link and it was Unsubscribe. It told a
tutor six tasks were waiting and the oldest was nine days old, then gave
them no way to reach any of them. It now opens on their marking queue at
/units/:id/tasks/inbox, in both parts.

Three things were saying different things about the same tasks.

The lead said nothing was overdue while a chart below said four were
still open. Two separate causes. task_definitions_and_status keeps seven
statuses, so a task in time_exceeded, feedback_exceeded, attention_
required, rediscuss or assess_in_portfolio is never eligible for the
deadline list however far past its date it is, and neither is one whose
target grade is outside the unit's grade values. And the pace figure
counted every assigned definition with a date on or before today, with no
status filter and with an inclusive comparison where the list uses an
exclusive one. There is now a branch for exactly that case, saying the
true thing: nothing reached the list, N tasks are past their target date
and unfinished, open the dashboard. The pace chart compares tasks whose
target date has passed against how many of those are finished, which are
two numbers about the same set.

The copy also used "due" for the deadline in one place and the target
date in another. The date on a task here is Task#due_date, the target
date adjusted for extensions, which is not TaskDefinition#due_date, the
date after which work stops being accepted. Everything that comes from
the first now says target.

"Your oldest overdue task" was false by construction: top_tasks orders by
target grade band and then by position in the unit, never by date, so the
first overdue entry is the lowest grade one. The three lists are sorted
by date in the mailer, which leaves top_tasks alone for the dashboard and
keeps the list, the lead and the subject in one order. The truncation
note fired at five or more when the cap is exactly five, telling a student
whose whole workload was five tasks that there were more; the cap is now
a named constant used by the condition and by both parts of the copy.

Every state sent the same subject, for both audiences, so a student who
filtered the quiet weeks lost the week they fell behind. Subjects now
lead on whatever is worst and staff get their own shape, so the two never
collide in one inbox. List-Unsubscribe goes on both, being scheduled bulk
mail. List-Unsubscribe-Post is deliberately left off: One-Click promises
a URL that takes an unauthenticated POST and acts on it, and this points
at a page behind a login.

Accessibility. The third row of "whose move" was coloured by state with
the same label and the same value either way, which is 4.5 of deltaE
apart under deuteranopia and carried nothing a sighted reader did not
already have; the Overdue section says it in words instead. Section
labels were styled paragraphs and are now h2 with the same inline styles.
The tutor table's two row header had no scope on any th. The plain text
chart labels were padded to thirty columns and truncated, which cut "To
talk through with your tutor" mid word.
A student in four units gets four emails, because the flow walks units
and then the projects inside them. This walks the student's projects
instead and sends one, and because it can see across units it can do the
thing four separate emails could never do: order every deadline into a
single "what to do next", whichever unit each belongs to.

It sits alongside weekly_student_summary rather than replacing it. That
one still sends per unit and still puts the unit's convenor in From and
its tutor in Reply-To, which this cannot, and whether to retire it is not
a decision to make here.

From and Reply-To. The per-unit mail could name a real person in both
because it only ever spoke for one unit. A digest spanning five has five
convenors and five tutors, and putting any one of them in Reply-To sends
a reply about one unit to the staff of another. So the institution sender
carries it, no Reply-To is set, and every unit block names that unit's
own tutor with a mailto beside it. The affordance survives and is now
correct per unit, which a single header could not be.

One template with the blocks gated on cadence, not three templates, so a
change to the deadline list is made once.

  Daily    lead, the cross-unit list, the button. Nothing else.
  Weekly   those plus the standing picture and the per-unit blocks.
  Monthly  no deadline list at all. It leads on what was finished over
           the month and whether that rate reaches the end of the unit.

Monthly drops deadlines because a date rendered "in two days" is read up
to a month after it was true, which teaches the reader that the email
lies. The charts the daily sheds are what the monthly is for.

Every window comes from the cadence. The figures the per-unit weekly
pinned to seven days, comments posted and tasks finished, are computed
over the run's own window here, so they are not merely stale in a daily
email but correct.

The cadence is a parameter rather than something read off the user, so
the scheduler owns who is mailed and this owns what the mail says. The
digest_frequency column and User#wants_digest_on? are not on this branch,
and the selection belongs in the rake task either way.

Units cannot in fact differ in host: config.institution[:host] is set
once per process from DF_INSTITUTION_HOST, so there is nothing to handle.
Unsubscribe was already per user rather than per unit role.
load_from_hash! calls destroy_removed_jobs, which only considers cron jobs
whose source is "schedule". Loading the file without that option registered
every job as "dynamic", so the bang did nothing: a job deleted or renamed in
config/schedule.yml kept its schedule in Redis and kept firing, and after a
rename both names ran.

The key has to be a string because the job reads args["source"] out of the
hash YAML filled. Jobs added by hand in the web UI stay dynamic and are still
left alone. Entries already in Redis are dynamic too, so each one needs
destroying by hand once.
Nothing triggered the summary email except one line in .ci-setup/crontab,
0 7 * * 1, which ran the per-unit mail and knew nothing about daily or
monthly. That crontab is only installed by pdfgen_entry_point.sh, so a
deployment without LaTeX has never sent a summary at all.

SendDigestEmailsJob now sends NotificationsMailer#student_digest for one
cadence, and config/schedule.yml registers the three: daily at 7am, weekly
Monday at 7:05am, monthly on the 1st at 7:10am, in the process timezone.
7am because that is the hour the weekly mail already used, so a student who
changes cadence sees no change in arrival time. Sidekiq rather than cron
because CheckUnitSimilarityJob already left that crontab for the same reason
and Sidekiq runs wherever the app sends mail.

The crontab line is deleted. The per-unit weekly mail still works and is
still rake mailer:send_status_emails, but it is on no schedule, because a
student on both would get the digest and a mail per unit the same morning.
Whether the digest supersedes it outright is a call for the team.

DigestDeliveryGuard stops a second copy. The digest had no guard of any kind:
Project#send_weekly_status_email sends with deliver_now and records nothing,
so every repeat run was another copy in every inbox. A claim is one Redis key
per student per period, taken before the send and handed back if the send
raises. The per-unit path is still unguarded and now says so.
Brings in notification target ids, the portfolio_submitted event for staff,
staff emails on the shared mail layout, the Unit Hub notification category
and its four events, the institution-managed name guard on profile updates,
automated comment detection, the digest_frequency preference, the sidekiq
schedule source fix and the scheduled student digest.

Conflicts:
- NotificationService.queue_email keeps 11.0.x's throttled early return,
  inline development delivery and queue_failed bookkeeping, and adds the
  per-channel email opt-in check. A notification skipped by that check is
  marked 'suppressed' instead of being left in 'pending'.
- NotificationEmailJob keeps 11.0.x's delivery-state handling and asks
  deliver_to? with channel: :email under the row lock.
- db/schema.rb takes the columns from both sides.

Migrations renamed so they sort after 20260922010000, the newest on 11.0.x.
Neither ran anywhere outside the branch:
- 20260915000000 -> 20260927000001_add_unit_hub_notification_preferences_to_users
- 20260916000000 -> 20260927000002_add_digest_frequency_to_users
Schema version is now 2026_09_27_000002.
Brings in the light mail palette, category icons and accents, the scannable
lead in every email, the task status palette, the reworked weekly summaries,
the Outlook-safe layout, recipient addresses built through Mail in every
mailer, and the all-units student digest that the scheduled digest job sends.

e7d7189 (the shared mail layout) was already merged through
api/notification-links, so it is not repeated.

Conflicts in task_comment_created.html.erb and task_submitted.html.erb:
kept this branch's details callout, category, label and note partial, and
kept api/notification-links' @notification_url button and the extra lead
line in task_submitted, so staff still land on the page the bell opens.
The schedule now holds 15 jobs: the session reminders and a daily, weekly
and monthly student digest. Also check there is one digest run per cadence.
The restyled plain text part dropped "Your extension request has been
assessed.", which the extension notification test still expects and the
HTML preheader still says.
The icons came in with the email styling, but these seven templates were
written on other branches and never set one.
portfolio_submitted, task_automated_comment_created and the four Unit Hub
events fell back to the generic subject. Each one now has its own, with a
test per event.
Split weekly_student_summary's pace count, returned count and subject into
private helpers and reuse digest_status_summary, so the method is back under
the AbcSize limit. Parenthesise the colour mix, prefer assert, and mark the
test-only update_columns calls.
…ences

Convenors and admins hold update_user, so they could opt another user in to
Unit Hub emails or change their digest frequency. These fields are now ignored
on a staff update, the same way theme_preference already is.
@Clupai8o0
Clupai8o0 merged commit a9589b3 into 11.0.x Sep 27, 2026
16 of 17 checks passed
This was referenced Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants