Skip to content

Commit 3791049

Browse files
Clupaimaplefoxgitswyamk3lgr2002KimSreng1
committed
feat(notifications): in-app, email and push notifications
Brings the T2 2026 notifications work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit <s223932052@deakin.edu.au> Co-authored-by: Maple Fox <s223932052@deakin.edu.au> Co-authored-by: Swyam Khare <s223759285@deakin.edu.au> Co-authored-by: lgr2002 <leeongouravrangey@gmail.com> Co-authored-by: Leeon Gourav Rangey <leeongouravrangey@gmail.com> Co-authored-by: Kimsreng <kimsrengin@gmail.com> Co-authored-by: Ronit Khokhar <ronitkhokharr@gmail.com> Co-authored-by: Tan Tai <s224621011@deakin.edu.au> Co-authored-by: shalitha99 <shalithabandara99@gmail.com> Co-authored-by: JOSHUA ERICKSON <jerickson@deakin.edu.au> Co-authored-by: Freddy <zhiyi9043@gmail.com> Co-authored-by: blankb0t <benee0394@gmail.com>
1 parent dc76a5a commit 3791049

180 files changed

Lines changed: 15486 additions & 259 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎NOTIFICATIONS.md‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Notifications
2+
3+
This explains how notifications work in OnTrack after the unification.
4+
5+
## The idea
6+
7+
Before, each type of message did its own thing. Emails were sent from many
8+
places. There was no single system.
9+
10+
Now there is one system. You send a notification once. It goes out on all the
11+
channels the user has turned on: in-app, email, and Web Push when the deployment
12+
has VAPID keys and the user has subscribed a browser.
13+
14+
## The flow
15+
16+
something happens in the app
17+
-> you call NotificationService.notify(...)
18+
-> saves an in-app notification (the bell)
19+
-> queues an ID-only email job
20+
-> queues an ID-only push job
21+
-> Sidekiq workers reload the notification and contact providers
22+
23+
You only call one thing. The system handles the rest.
24+
25+
## How to send one
26+
27+
Call this from anywhere in the API code:
28+
29+
NotificationService.notify(
30+
user: project.student,
31+
type: 'feedback',
32+
event: 'task_comment_created',
33+
message: "New feedback is ready for #{task_definition.name}.",
34+
link: "/#/projects/#{project.id}"
35+
)
36+
37+
- user: who gets it.
38+
- type: the category. One of task, feedback, portfolio, extension, general.
39+
This is what the user's on/off setting controls.
40+
- event: the specific thing that happened, as a lower_snake_case string.
41+
Required. Use one event name per ticket, and use the same name every time you
42+
raise that notification, so a notification can always be traced back to the
43+
code that sent it.
44+
- message: the text the user sees. Keep it short, 500 characters at most.
45+
- link: where clicking it should take them. Optional.
46+
47+
type and event are different on purpose. type is the coarse category the user
48+
switches off in their profile. event is the fine-grained reason, and there will
49+
be many events inside one type.
50+
51+
## Types and preferences
52+
53+
Each user already has three on/off settings in their profile:
54+
55+
- receive_task_notifications
56+
- receive_feedback_notifications
57+
- receive_portfolio_notifications
58+
59+
The type you pass maps to one of these settings.
60+
61+
- task uses receive_task_notifications
62+
- feedback uses receive_feedback_notifications
63+
- portfolio uses receive_portfolio_notifications
64+
- extension and general are always sent
65+
66+
If the matching setting is off, nothing is sent. Not the bell, not the email,
67+
not the push. One switch controls all channels. This keeps it simple. We can add
68+
per-channel switches later if we want.
69+
70+
## The pieces
71+
72+
- app/models/notification.rb: the notification record. Has the type, message,
73+
link, and whether it has been read.
74+
- app/services/notification_service.rb: the one entry point. Checks the setting,
75+
saves the record, and queues the email and push channel jobs.
76+
- app/sidekiq/notification_email_job.rb: reloads a notification by id and sends
77+
its email on the `mailers` queue.
78+
- app/sidekiq/push_notification_delivery_job.rb: reloads a notification by id
79+
and hands it to the Web Push delivery channel on the `notifications` queue.
80+
- app/services/push_notification_service.rb: the Web Push delivery channel. It
81+
remains a safe no-op until both VAPID keys are configured.
82+
- app/mailers/notifications_mailer.rb: the email. New method single_notification
83+
with templates in app/views/notifications_mailer.
84+
- app/api/notifications_api.rb: the endpoints the web app calls.
85+
- app/api/entities/notification_entity.rb: the shape of the data sent back.
86+
87+
## The endpoints
88+
89+
GET /api/notifications list my notifications
90+
GET /api/notifications/unread_count how many I have not read
91+
PUT /api/notifications/:id/read mark one as read
92+
PUT /api/notifications/read_all mark all as read
93+
DELETE /api/notifications/:id delete one
94+
95+
GET /api/push_subscriptions list my browser subscriptions
96+
POST /api/push_subscriptions register or update a browser
97+
DELETE /api/push_subscriptions remove the browser identified by endpoint
98+
99+
Every endpoint only ever touches the current user's own notifications.
100+
101+
## What is on now
102+
103+
- In-app: working. The record is saved and the endpoints return it.
104+
- Email: working. Best effort. If email fails, the in-app notification is still
105+
saved.
106+
- Push: implemented and deliberately configuration-gated. It sends only when
107+
VAPID keys are set and that user has opted in from a supported browser. Keep
108+
the production keys blank until browser/device acceptance testing is complete.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
class AdditionalNotificationEmailVerificationApi < Grape::API
4+
desc 'Verify ownership of an additional notification email'
5+
params do
6+
requires :token, type: String
7+
end
8+
post '/additional_notification_emails/verify' do
9+
header 'Cache-Control', 'private, no-store'
10+
AdditionalNotificationEmailService.verify(token: params[:token])
11+
{ status: 'verified' }
12+
rescue AdditionalNotificationEmailService::AlreadyVerified
13+
error!({ error: 'This verification link has already been used.' }, 409)
14+
rescue AdditionalNotificationEmailService::InvalidToken
15+
error!({ error: 'This verification link is invalid or has expired.' }, 422)
16+
end
17+
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
class AdditionalNotificationEmailsApi < Grape::API
4+
helpers AuthenticationHelpers
5+
6+
before do
7+
authenticated?
8+
header 'Cache-Control', 'private, no-store'
9+
end
10+
11+
helpers do
12+
def own_user!
13+
error!({ error: 'You can only manage your own additional notification email.' }, 403) unless params[:user_id] == current_user.id
14+
15+
current_user
16+
end
17+
18+
def present_additional_email(record)
19+
if record.nil?
20+
{ status: 'none', email: nil, verification_expires_at: nil }
21+
else
22+
{
23+
status: record.verified? ? 'verified' : 'pending',
24+
email: record.email,
25+
verification_expires_at: record.verification_expires_at
26+
}
27+
end
28+
end
29+
end
30+
31+
desc 'Get the current user additional notification email state'
32+
params do
33+
requires :user_id, type: Integer
34+
end
35+
get '/users/:user_id/additional_notification_email' do
36+
user = own_user!
37+
present present_additional_email(user.additional_notification_email)
38+
end
39+
40+
desc 'Request or replace an additional notification email'
41+
params do
42+
requires :user_id, type: Integer
43+
requires :email, type: String
44+
end
45+
put '/users/:user_id/additional_notification_email' do
46+
user = own_user!
47+
record = AdditionalNotificationEmailService.request(user: user, email: params[:email])
48+
present present_additional_email(record)
49+
rescue AdditionalNotificationEmailService::RateLimited
50+
error!({ error: 'Too many verification requests. Try again in one hour.' }, 429)
51+
end
52+
53+
desc 'Resend additional notification email verification'
54+
params do
55+
requires :user_id, type: Integer
56+
end
57+
post '/users/:user_id/additional_notification_email/resend' do
58+
user = own_user!
59+
record = AdditionalNotificationEmailService.resend(user: user)
60+
present present_additional_email(record)
61+
rescue AdditionalNotificationEmailService::AlreadyVerified
62+
error!({ error: 'This additional notification email is already verified.' }, 409)
63+
rescue AdditionalNotificationEmailService::RateLimited
64+
error!({ error: 'Too many verification requests. Try again in one hour.' }, 429)
65+
end
66+
67+
desc 'Remove the current user additional notification email'
68+
params do
69+
requires :user_id, type: Integer
70+
end
71+
delete '/users/:user_id/additional_notification_email' do
72+
user = own_user!
73+
AdditionalNotificationEmailService.remove(user: user)
74+
status 204
75+
body false
76+
end
77+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module Entities
2+
class NotificationEntity < Grape::Entity
3+
expose :id
4+
expose :notification_type
5+
expose :event
6+
expose :message
7+
expose :link
8+
expose :read_at
9+
expose :created_at
10+
end
11+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module Entities
2+
class PushSubscriptionEntity < Grape::Entity
3+
expose :id
4+
expose :endpoint
5+
expose :created_at
6+
expose :updated_at
7+
8+
# p256dh and auth are deliberately not exposed. They are the browser's own
9+
# encryption material, the client already holds them, and nothing in the UI
10+
# needs them read back.
11+
end
12+
end

‎app/api/notifications_api.rb‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
require 'grape'
2+
3+
class NotificationsApi < Grape::API
4+
helpers AuthenticationHelpers
5+
helpers AuthorisationHelpers
6+
7+
before do
8+
authenticated?
9+
end
10+
11+
desc 'Get the current user notifications'
12+
params do
13+
optional :unread_only, type: Boolean, default: false, desc: 'Only return unread notifications'
14+
end
15+
get '/notifications' do
16+
notifications = current_user.notifications.recent_first
17+
notifications = notifications.unread if params[:unread_only]
18+
19+
present notifications, with: Entities::NotificationEntity
20+
end
21+
22+
desc 'Get the current user unread notification count'
23+
get '/notifications/unread_count' do
24+
{ count: current_user.notifications.unread.count }
25+
end
26+
27+
desc 'Mark a notification as read'
28+
params do
29+
requires :id, type: Integer, desc: 'The notification id'
30+
end
31+
put '/notifications/:id/read' do
32+
notification = current_user.notifications.find(params[:id])
33+
notification.mark_read!
34+
35+
present notification, with: Entities::NotificationEntity
36+
end
37+
38+
desc 'Mark all of the current user notifications as read'
39+
put '/notifications/read_all' do
40+
# rubocop:disable Rails/SkipsModelValidations
41+
current_user.notifications.unread.update_all(read_at: Time.zone.now)
42+
# rubocop:enable Rails/SkipsModelValidations
43+
44+
status 200
45+
{ success: true }
46+
end
47+
48+
desc 'Delete the current user notifications up to a confirmed boundary'
49+
params do
50+
requires :through_id,
51+
type: Integer,
52+
values: ->(value) { value.positive? },
53+
desc: 'Delete only notifications whose id is at or below this value'
54+
end
55+
delete '/notifications' do
56+
# The boundary is part of the contract, not an optimisation. A notification
57+
# can arrive after the browser has shown "Delete all" and while the user is
58+
# reading the confirmation. Deleting the unbounded association here would
59+
# remove that unseen notification too. Scoping through current_user keeps
60+
# the operation account-local in the same way as the single-row endpoint.
61+
deleted_count = current_user.notifications
62+
.where('notifications.id <= ?', params[:through_id])
63+
.delete_all
64+
65+
status 200
66+
{ success: true, deleted_count: deleted_count }
67+
end
68+
69+
desc 'Delete a notification'
70+
params do
71+
requires :id, type: Integer, desc: 'The notification id'
72+
end
73+
delete '/notifications/:id' do
74+
notification = current_user.notifications.find(params[:id])
75+
notification.destroy!
76+
77+
status 200
78+
{ success: true }
79+
end
80+
end

‎app/api/push_subscriptions_api.rb‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require 'grape'
2+
3+
class PushSubscriptionsApi < Grape::API
4+
helpers AuthenticationHelpers
5+
helpers AuthorisationHelpers
6+
7+
before do
8+
authenticated?
9+
end
10+
11+
desc 'Get the push subscriptions belonging to the current user'
12+
get '/push_subscriptions' do
13+
present current_user.push_subscriptions.order(:id), with: Entities::PushSubscriptionEntity
14+
end
15+
16+
# The endpoint posted here is later used as the target of an outbound request
17+
# by PushNotificationService, so it is not accepted as free text.
18+
# PushSubscription validates it against PUSH_SERVICE_HOSTS, and a URL that is
19+
# not an https push service URL fails with a 400 from the handler in
20+
# api_root.rb. PushNotificationService checks again before it sends.
21+
desc 'Register this browser to receive push notifications'
22+
params do
23+
requires :endpoint, type: String, desc: 'The push service URL, from PushSubscription.endpoint'
24+
requires :p256dh, type: String, desc: 'The browser public key, from PushSubscription.getKey("p256dh")'
25+
requires :auth, type: String, desc: 'The browser auth secret, from PushSubscription.getKey("auth")'
26+
end
27+
post '/push_subscriptions' do
28+
subscription = current_user.push_subscriptions.find_by(endpoint: params[:endpoint])
29+
30+
# Not ours, or not stored yet. An endpoint identifies a browser rather than
31+
# a person, so an endpoint held by another user means someone has signed in
32+
# on a machine that account used. Move the registration across instead of
33+
# failing on the unique index.
34+
#
35+
# This is the only lookup in this file that is not scoped to current_user,
36+
# and it is safe. The push service delivers to that browser no matter which
37+
# row owns it, and the payload is encrypted to the keys posted here. Taking
38+
# over someone else's endpoint cannot read their notifications, it can only
39+
# stop them arriving, and you need the endpoint URL to try it at all.
40+
subscription ||= PushSubscription.find_by(endpoint: params[:endpoint]) || PushSubscription.new
41+
42+
subscription.assign_attributes(
43+
user: current_user,
44+
endpoint: params[:endpoint],
45+
p256dh: params[:p256dh],
46+
auth: params[:auth]
47+
)
48+
subscription.save!
49+
50+
present subscription, with: Entities::PushSubscriptionEntity
51+
end
52+
53+
desc 'Stop this browser receiving push notifications'
54+
params do
55+
requires :endpoint, type: String, desc: 'The push service URL to remove'
56+
end
57+
delete '/push_subscriptions' do
58+
subscription = current_user.push_subscriptions.find_by!(endpoint: params[:endpoint])
59+
subscription.destroy!
60+
61+
status 200
62+
{ success: true }
63+
end
64+
end

0 commit comments

Comments
 (0)