Repository navigation
feat(notifications): land notification links, Unit Hub alerts and the digest email - #178
Merged
Merged
Conversation
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.
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
This lands two api branches that never got a PR:
api/notification-links(12 commits) andui/email-styling(10 commits). The web side is onintegration/t2-2026and is being landed next, so the notification deep links, the Unit Hub alerts and the digest setting need this first.What is in it
linkand are null when the record is gone.automated, so they can be shown apart from a tutor's comment.PUT /users/:idreturns 422 when someone who signs in through their institution tries to change their first or last name.schedule.yml.How it was built
api/notification-linksinb8cc5dc8, thenui/email-stylingin3e4d19a4. They share one commit, which comes in once.11.0.xalready did the same thing I kept its version:address_with_name, thenotifiablecolumn, theSUBJECTStable and the email delivery states. The per-channel opt-in is added on top, so an email the user has not opted into is markedsuppressedrather than leftpending.11e800b4): staff withupdate_usercould change another user's Unit Hub opt-ins and digest frequency. Those fields are now ignored unless you are updating yourself, the same astheme_preference.Migrations
20260927000001adds the four Unit Hub preference columns to users.20260927000002addsdigest_frequency.20260915000000and20260916000000on the branch. I renumbered them so they sort after the newest11.0.xmigration (20260922010000). Neither has run in production.2026_09_27_000002. I randb:populateon a separate test database andgit diff --exit-code -- db/schema.rbcame back clean.Built against
land/notification-links@11e800b4, on11.0.x@5de649d711.0.x@192078b93(the web half follows in its own PR)11.0.x@1ca0413Testing
test/api/users_test.rb: 42 runs, 0 failures, with a new test for the staff update.ruby -con every changed file.Known open items
task.rb:1559,accept_overseer_job.rb:111) are not flagged as automated yet. That was already the case on the branch.SendDigestEmailsJobstill has an old comment saying the mailer lives on another branch.