Skip to content

Commit 012b5cb

Browse files
committed
Merge main
2 parents d7c2a6e + 0fac6df commit 012b5cb

45 files changed

Lines changed: 2014 additions & 156 deletions

Some content is hidden

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

share-api.cabal

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ library
4848
Share.Monitoring
4949
Share.Names.Postgres
5050
Share.NamespaceDiffs
51+
Share.Notifications.API
52+
Share.Notifications.Impl
53+
Share.Notifications.Queries
54+
Share.Notifications.Types
5155
Share.Postgres
5256
Share.Postgres.Admin
5357
Share.Postgres.Authorization.Queries
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
-- Some resources grant permissions publicly regardless of user.
2+
CREATE OR REPLACE VIEW public_resource_permissions(resource_id, permission) AS (
3+
SELECT p.resource_id, permission
4+
FROM projects p
5+
, roles r
6+
, UNNEST(r.permissions) AS permission
7+
WHERE NOT p.private
8+
AND r.ref = 'project_public_access'
9+
);
10+
11+
12+
CREATE OR REPLACE VIEW subject_resource_permissions(subject_id, resource_id, permission) AS (
13+
WITH base_permissions(subject_id, resource_id, permission) AS (
14+
-- base permissions
15+
SELECT rm.subject_id, rm.resource_id, permission
16+
FROM role_memberships rm
17+
JOIN roles r ON rm.role_id = r.id
18+
, UNNEST(r.permissions) AS permission
19+
) SELECT * FROM base_permissions
20+
UNION
21+
-- Inherit permissions from parent resources
22+
SELECT bp.subject_id, rh.resource_id, bp.permission
23+
FROM base_permissions bp
24+
JOIN resource_hierarchy rh ON bp.resource_id = rh.parent_resource_id
25+
UNION
26+
-- Include public resource permissions
27+
SELECT NULL, prp.resource_id, permission
28+
FROM public_resource_permissions prp
29+
);
30+
31+
CREATE OR REPLACE VIEW user_resource_permissions(user_id, resource_id, permission) AS (
32+
SELECT sbu.user_id, srp.resource_id, permission
33+
FROM subjects_by_user sbu
34+
JOIN subject_resource_permissions srp
35+
ON sbu.subject_id = srp.subject_id
36+
UNION
37+
-- Include public resource permissions
38+
SELECT NULL, prp.resource_id, permission
39+
FROM public_resource_permissions prp
40+
);
41+
42+
-- work this table into the permissions system
43+
CREATE OR REPLACE FUNCTION user_has_permission(user_id UUID, resource_id UUID, permission permission)
44+
RETURNS BOOLEAN
45+
STABLE
46+
PARALLEL SAFE
47+
AS $$
48+
SELECT EXISTS (
49+
SELECT
50+
FROM user_resource_permissions urp
51+
WHERE (urp.user_id IS NULL OR urp.user_id = $1) AND urp.resource_id = $2 AND urp.permission = $3
52+
);
53+
$$ LANGUAGE SQL;

sql/2025-04-09_notifications.sql

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
-- The groundwork for a generalized notification system, which can be used to notify users of various events.
2+
--
3+
-- The basis of the system is that:
4+
-- users have *subscriptions*,
5+
-- which apply filters to *topics*,
6+
-- which contain *events*,
7+
-- that then generate *notifications*,
8+
-- which are sent via *delivery methods*
9+
10+
-- E.g.
11+
-- A user *subscribes* to receive *emails* for the "New contributions" *topic* with a filter for the `foo` project.
12+
--
13+
-- Later, A user creates a contribution on that project,
14+
-- That *event* is handled by all relevant *subscriptions*,
15+
-- Which generates a *notification* for the user,
16+
-- which is then sent via the *email* *delivery method* and marked completed.
17+
--
18+
-- A note on permissions:
19+
-- We allow creating all kinds of notification subscriptions, even for things the calling user
20+
-- doesn't have access to, but the notification system will only actually create notifications if the caller has access to
21+
-- the resource of a given event for the permission associated to that topic via the
22+
-- 'topic_permission' SQL function.
23+
--
24+
-- This means that if the permissions associated to a given resource change, the notification system will correctly
25+
-- adapt which notifications its sending over time. And we avoid the nebulous task of determining which possible
26+
-- resources any given subscription _might_ be associated with. This allows subscriptions to be more general as well,
27+
-- since they can be wide-sweeping wildcard subscriptions which are not constrained to a specific resource.
28+
29+
CREATE TYPE notification_topic AS ENUM (
30+
'project:branch:updated',
31+
'project:contribution:created'
32+
);
33+
34+
-- Returns the list of permissions a user must have for an event's resource in order to be notified.
35+
CREATE FUNCTION topic_permission(topic notification_topic)
36+
RETURNS permission
37+
PARALLEL SAFE
38+
IMMUTABLE
39+
AS $$
40+
BEGIN
41+
CASE topic
42+
WHEN 'project:branch:updated' THEN
43+
RETURN 'project:view'::permission;
44+
WHEN 'project:contribution:created' THEN
45+
RETURN 'project:view'::permission;
46+
ELSE
47+
RAISE EXCEPTION 'topic_permissions: topic % must declare its necessary permissions', topic;
48+
END CASE;
49+
END;
50+
$$ LANGUAGE plpgsql;
51+
52+
CREATE TABLE notification_events (
53+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
54+
occurred_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
55+
56+
-- The resource associated with the event, to check if subscribers have permission to be notified.
57+
resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
58+
59+
topic notification_topic NOT NULL,
60+
-- The effective scope of this event. The user_id of the relevant user or org.
61+
scope_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
62+
data JSONB NOT NULL
63+
);
64+
65+
CREATE INDEX notification_events_topic ON notification_events(topic, occurred_at DESC);
66+
CREATE INDEX notification_events_scope_user ON notification_events(scope_user_id, occurred_at DESC);
67+
68+
CREATE TABLE notification_subscriptions (
69+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
70+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
71+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
72+
73+
-- user_id of the subscriber.
74+
subscriber_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
75+
76+
-- The scope of this subscription.
77+
scope_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
78+
-- The topics this subscription is for.
79+
topics notification_topic[] NOT NULL CHECK (array_length(topics, 1) > 0),
80+
-- Any additional filtering for this subscription, e.g. which projects we care about, etc.
81+
-- Specified as an object with key-value pairs which must ALL be present on the event in order to trigger
82+
-- the notification.
83+
filter JSONB NULL
84+
);
85+
86+
CREATE TRIGGER notification_subscriptions_updated_at
87+
BEFORE UPDATE ON notification_subscriptions
88+
FOR EACH ROW
89+
EXECUTE PROCEDURE moddatetime (updated_at);
90+
91+
-- GIN index for finding subscriptions by topic
92+
CREATE INDEX notification_subscriptions_by_topic ON notification_subscriptions USING GIN (topics, scope_user_id);
93+
94+
CREATE INDEX notification_subscriptions_by_user ON notification_subscriptions(subscriber_user_id, created_at DESC);
95+
96+
-- Which notifications were triggered by which subscription for each event.
97+
CREATE TABLE notification_providence_log (
98+
event_id UUID REFERENCES notification_events(id) ON DELETE CASCADE,
99+
subscription_id UUID REFERENCES notification_subscriptions(id) ON DELETE CASCADE,
100+
PRIMARY KEY (event_id, subscription_id)
101+
);
102+
103+
CREATE TABLE notification_webhooks (
104+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
105+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
106+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
107+
108+
-- Who owns (and can edit) this delivery method
109+
subscriber_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
110+
111+
-- The URL to send the webhook to.
112+
url TEXT NOT NULL CHECK (url <> '')
113+
);
114+
115+
CREATE INDEX notification_webhooks_by_user ON notification_webhooks(subscriber_user_id, url);
116+
117+
CREATE TRIGGER notification_webhooks_updated_at
118+
BEFORE UPDATE ON notification_webhooks
119+
FOR EACH ROW
120+
EXECUTE PROCEDURE moddatetime (updated_at);
121+
122+
CREATE TABLE notification_by_webhook (
123+
subscription_id UUID REFERENCES notification_subscriptions(id) ON DELETE CASCADE,
124+
webhook_id UUID REFERENCES notification_webhooks(id) ON DELETE CASCADE,
125+
PRIMARY KEY (subscription_id, webhook_id)
126+
);
127+
128+
CREATE INDEX notification_by_webhook_by_webhook_id ON notification_by_webhook(webhook_id);
129+
130+
CREATE TABLE notification_emails (
131+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
132+
-- Who owns (and can edit) this delivery method
133+
subscriber_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
134+
135+
-- The email address to send the email to.
136+
email TEXT NOT NULL CHECK (email <> ''),
137+
138+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
139+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
140+
);
141+
142+
CREATE INDEX notification_emails_by_user ON notification_emails(subscriber_user_id, email);
143+
144+
CREATE TRIGGER notification_emails_updated_at
145+
BEFORE UPDATE ON notification_emails
146+
FOR EACH ROW
147+
EXECUTE PROCEDURE moddatetime (updated_at);
148+
149+
CREATE TABLE notification_by_email (
150+
subscription_id UUID REFERENCES notification_subscriptions(id) ON DELETE CASCADE,
151+
email_id UUID REFERENCES notification_emails(id) ON DELETE CASCADE,
152+
PRIMARY KEY (subscription_id, email_id)
153+
);
154+
155+
CREATE INDEX notification_by_email_by_email_id ON notification_by_email(email_id);
156+
157+
CREATE TABLE notification_webhook_queue (
158+
event_id UUID REFERENCES notification_events(id) ON DELETE CASCADE,
159+
webhook_id UUID REFERENCES notification_webhooks(id) ON DELETE CASCADE,
160+
delivery_attempts_remaining INTEGER NOT NULL DEFAULT 3,
161+
delivered BOOLEAN DEFAULT FALSE,
162+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
163+
164+
PRIMARY KEY (event_id, webhook_id)
165+
);
166+
167+
-- Allow efficiently grabbing the oldest undelivered webhooks we're still trying to deliver.
168+
CREATE INDEX notification_webhook_queue_undelivered ON notification_webhook_queue(created_at ASC)
169+
WHERE NOT delivered AND delivery_attempts_remaining > 0;
170+
171+
CREATE TABLE notification_email_queue (
172+
event_id UUID REFERENCES notification_events(id) ON DELETE CASCADE,
173+
email_id UUID REFERENCES notification_emails(id) ON DELETE CASCADE,
174+
delivery_attempts_remaining INTEGER NOT NULL DEFAULT 3,
175+
delivered BOOLEAN DEFAULT FALSE,
176+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
177+
178+
PRIMARY KEY (event_id, email_id)
179+
);
180+
181+
-- Allow efficiently grabbing the oldest undelivered emails we're still trying to deliver.
182+
CREATE INDEX notification_email_queue_undelivered ON notification_email_queue(created_at ASC)
183+
WHERE NOT delivered AND delivery_attempts_remaining > 0;
184+
185+
CREATE TYPE notification_status AS ENUM (
186+
'unread',
187+
'read',
188+
'archived'
189+
);
190+
191+
CREATE TABLE notification_hub_entries (
192+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
193+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
194+
event_id UUID REFERENCES notification_events(id) ON DELETE CASCADE,
195+
status notification_status NOT NULL DEFAULT 'unread',
196+
197+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
198+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
199+
);
200+
201+
CREATE UNIQUE INDEX notification_hub_entries_event_user ON notification_hub_entries(user_id, event_id);
202+
CREATE INDEX notification_hub_entries_by_user_chronological ON notification_hub_entries(user_id, created_at DESC)
203+
WHERE status <> 'archived';
204+
205+
CREATE TRIGGER notification_hub_entries_updated_at
206+
BEFORE UPDATE ON notification_hub_entries
207+
FOR EACH ROW
208+
EXECUTE PROCEDURE moddatetime (updated_at);
209+
210+
-- Add a trigger to automatically add to notification queues for relevant subscriptions.
211+
CREATE FUNCTION trigger_notification_event_subscriptions()
212+
RETURNS TRIGGER AS $$
213+
DECLARE
214+
the_subscription_id UUID;
215+
the_event_id UUID;
216+
the_subscriber UUID;
217+
BEGIN
218+
SELECT NEW.id INTO the_event_id;
219+
FOR the_subscription_id, the_subscriber IN
220+
(SELECT ns.id, ns.subscriber_user_id FROM notification_subscriptions ns
221+
WHERE ns.scope_user_id = NEW.scope_user_id
222+
AND NEW.topic = ANY(ns.topics)
223+
AND (ns.filter IS NULL OR NEW.data @> ns.filter)
224+
AND
225+
-- A subscriber can be notified if the event is in their scope or if they have permission to the resource.
226+
-- The latter is usually a superset of the former, but the former is trivial to compute so it can help
227+
-- performance to include it.
228+
(NEW.scope_user_id = ns.subscriber_user_id
229+
OR user_has_permission(ns.subscriber_user_id, NEW.resource_id, topic_permission(NEW.topic))
230+
)
231+
)
232+
LOOP
233+
-- Log that this event triggered this subscription.
234+
INSERT INTO notification_providence_log (event_id, subscription_id)
235+
VALUES (the_event_id, the_subscription_id);
236+
237+
-- Add to the relevant queues.
238+
-- Each delivery method _may_ be triggered by multiple subscriptions,
239+
-- we need ON CONFLICT DO NOTHING.
240+
INSERT INTO notification_webhook_queue (event_id, webhook_id)
241+
SELECT the_event_id, nbw.webhook_id
242+
FROM notification_by_webhook nbw
243+
WHERE nbw.subscription_id = the_subscription_id
244+
ON CONFLICT DO NOTHING;
245+
246+
INSERT INTO notification_email_queue (event_id, email_id)
247+
SELECT the_event_id AS event_id, nbe.email_id
248+
FROM notification_by_email nbe
249+
WHERE nbe.subscription_id = the_subscription_id
250+
ON CONFLICT DO NOTHING;
251+
252+
-- Also add the notification to the hub.
253+
-- It's possible it was already added by another subscription for this user,
254+
-- in which case we just carry on.
255+
INSERT INTO notification_hub_entries (event_id, user_id)
256+
VALUES (the_event_id, the_subscriber)
257+
ON CONFLICT DO NOTHING;
258+
END LOOP;
259+
260+
RETURN NEW;
261+
END;
262+
$$ LANGUAGE plpgsql;
263+
264+
CREATE TRIGGER notification_event_subscriptions
265+
AFTER INSERT ON notification_events
266+
FOR EACH ROW
267+
EXECUTE FUNCTION trigger_notification_event_subscriptions();
268+
269+
270+
271+
-- Add new permissions to existing roles
272+
UPDATE roles r
273+
SET permissions = r.permissions || '{"notification_hub_entry:view", "notification_hub_entry:update", "notification_delivery_method:view", "notification_delivery_method:manage", "notification_subscription:view", "notification_subscription:manage"}'
274+
WHERE r.ref IN ('org_admin'::role_ref, 'org_owner'::role_ref, 'org_default'::role_ref, 'org_maintainer'::role_ref);

src/Share/IDs.hs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ module Share.IDs
3939
ResourceId (..),
4040
OrgId (..),
4141
TeamId (..),
42+
NotificationHubEntryId (..),
43+
NotificationEventId (..),
44+
NotificationEmailDeliveryMethodId (..),
45+
NotificationWebhookId (..),
46+
NotificationSubscriptionId (..),
47+
Email (..),
4248
projectBranchShortHandToBranchShortHand,
4349
JTI (..),
4450
CategoryName (..),
@@ -712,3 +718,32 @@ newtype TeamId = TeamId UUID
712718
deriving stock (Eq, Ord)
713719
deriving (Hasql.EncodeValue, Hasql.DecodeValue) via UUID
714720
deriving (Show, FromHttpApiData, ToHttpApiData, ToJSON, FromJSON, IsID) via (PrefixedID "TEAM-" UUID)
721+
722+
newtype NotificationHubEntryId = NotificationHubEntryId UUID
723+
deriving stock (Eq, Ord)
724+
deriving (Hasql.EncodeValue, Hasql.DecodeValue) via UUID
725+
deriving (Show, FromHttpApiData, ToHttpApiData, ToJSON, FromJSON, IsID) via (PrefixedID "NOT-" UUID)
726+
727+
newtype NotificationEventId = NotificationEventId UUID
728+
deriving stock (Eq, Ord)
729+
deriving (Hasql.EncodeValue, Hasql.DecodeValue) via UUID
730+
deriving (Show, FromHttpApiData, ToHttpApiData, ToJSON, FromJSON, IsID) via (PrefixedID "EVENT-" UUID)
731+
732+
newtype NotificationEmailDeliveryMethodId = NotificationEmailDeliveryMethodId UUID
733+
deriving stock (Eq, Ord)
734+
deriving (Hasql.EncodeValue, Hasql.DecodeValue) via UUID
735+
deriving (Show, FromHttpApiData, ToHttpApiData, ToJSON, FromJSON, IsID) via (PrefixedID "NE-" UUID)
736+
737+
newtype NotificationWebhookId = NotificationWebhookId UUID
738+
deriving stock (Eq, Ord)
739+
deriving (Hasql.EncodeValue, Hasql.DecodeValue) via UUID
740+
deriving (Show, FromHttpApiData, ToHttpApiData, ToJSON, FromJSON, IsID) via (PrefixedID "NW-" UUID)
741+
742+
newtype NotificationSubscriptionId = NotificationSubscriptionId UUID
743+
deriving stock (Eq, Ord)
744+
deriving (Hasql.EncodeValue, Hasql.DecodeValue) via UUID
745+
deriving (Show, FromHttpApiData, ToHttpApiData, ToJSON, FromJSON, IsID) via (PrefixedID "NS-" UUID)
746+
747+
newtype Email = Email Text
748+
deriving stock (Eq, Ord, Show)
749+
deriving (Hasql.EncodeValue, Hasql.DecodeValue, FromHttpApiData, ToHttpApiData, ToJSON, FromJSON) via Text

0 commit comments

Comments
 (0)