diff --git a/sql/2025-09-03_project-webhooks.sql b/sql/2025-09-03_project-webhooks.sql new file mode 100644 index 00000000..cff86455 --- /dev/null +++ b/sql/2025-09-03_project-webhooks.sql @@ -0,0 +1,169 @@ +-- This changes webhooks such that each webhook and email delivery method is assigned to exactly one subscription, rather than the +-- previous many-to-many relationship. + +-- Add a new topic group which represents _all_ activity in a project. +ALTER TYPE notification_topic_group ADD VALUE IF NOT EXISTS 'all_project_topics'; + +INSERT INTO notification_topic_group_topics (topic_group, topic) + VALUES ('all_project_topics', 'project:branch:updated'), + ('all_project_topics', 'project:contribution:created'), + ('all_project_topics', 'project:contribution:updated'), + ('all_project_topics', 'project:contribution:comment'), + ('all_project_topics', 'project:ticket:created'), + ('all_project_topics', 'project:ticket:updated'), + ('all_project_topics', 'project:ticket:comment'), + ('all_project_topics', 'project:release:created'); + +-- WEBHOOKS +ALTER TABLE notification_webhooks + ADD COLUMN subscription_id UUID NULL REFERENCES notification_subscriptions(id) ON DELETE CASCADE; + +CREATE INDEX notification_webhooks_by_subscription ON notification_webhooks(subscription_id); + +UPDATE notification_webhooks nw + SET subscription_id = (SELECT nbw.subscription_id FROM notification_by_webhook nbw WHERE nbw.webhook_id = nw.id LIMIT 1); + +ALTER TABLE notification_webhooks + ALTER COLUMN subscription_id SET NOT NULL; + +-- EMAILS +ALTER TABLE notification_emails + ADD COLUMN subscription_id UUID NULL REFERENCES notification_subscriptions(id) ON DELETE CASCADE; + +CREATE INDEX notification_emails_by_subscription ON notification_emails(subscription_id); + +UPDATE notification_emails ne + SET subscription_id = (SELECT nbe.subscription_id FROM notification_by_email nbe WHERE nbe.email_id = ne.id LIMIT 1); + + +ALTER TABLE notification_emails + ALTER COLUMN subscription_id SET NOT NULL; + + + +-- This migration codifies the project ID filter on notification subscriptions into a real field rather than a +-- generic JSONB filter. You can still set it to NULL for org-wide subscriptions. +ALTER TABLE notification_subscriptions + -- The project which this filter is scoped to. + -- If provided, the project_id must be belong to the scope_user_id's user/org, or match the subscriber_project_id. + ADD COLUMN scope_project_id UUID NULL REFERENCES projects(id) ON DELETE CASCADE, + -- A subscription can belong to a project itself. + ADD COLUMN subscriber_project_id UUID NULL REFERENCES projects(id) ON DELETE CASCADE, + -- Project subscriptions won't have a subscriber_user_id, just a subscriber_project_id. + -- So allow subscriber_user_id to be nullable + ALTER COLUMN subscriber_user_id DROP NOT NULL, + -- Add a constraint that either subscriber_user_id or subscriber_project_id must be set, but not both. + ADD CONSTRAINT notification_subscriptions_user_or_project CHECK ( + (subscriber_user_id IS NOT NULL AND subscriber_project_id IS NULL) + OR (subscriber_user_id IS NULL AND subscriber_project_id IS NOT NULL) + ), + -- Add a constraint that if the subscriber is a project, the scope_project_id must match the subscriber_project_id. + ADD CONSTRAINT notification_subscriptions_scope_project_matches CHECK ( + (subscriber_project_id IS NULL) + OR (scope_project_id IS NOT DISTINCT FROM subscriber_project_id) + ); + +-- Index to find for a user scoped to a specific project. +CREATE INDEX notification_subscriptions_by_user_and_project ON notification_subscriptions(subscriber_user_id, scope_project_id, created_at DESC); + +ALTER TABLE notification_events + ADD COLUMN project_id UUID NULL REFERENCES projects(id) ON DELETE CASCADE; + +CREATE INDEX notification_events_scope_user_and_project ON notification_events(scope_user_id, project_id, occurred_at DESC); + +-- Migrate existing filters to the new column, and also remove +-- the projectId from the JSONB filter. +UPDATE notification_subscriptions + SET scope_project_id = (filter->>'projectId')::UUID, + filter = filter - 'projectId' + WHERE filter ? 'projectId'; + +UPDATE notification_events + SET project_id = (data->>'projectId')::UUID + WHERE data ? 'projectId'; + +-- Rework the trigger to use the new topic groups. +CREATE OR REPLACE FUNCTION trigger_notification_event_subscriptions() +RETURNS TRIGGER AS $$ +DECLARE + the_subscription_id UUID; + the_event_id UUID; + the_subscriber UUID; + rows_affected INTEGER; +BEGIN + SELECT NEW.id INTO the_event_id; + FOR the_subscription_id, the_subscriber IN + (SELECT ns.id, ns.subscriber_user_id FROM notification_subscriptions ns + WHERE ns.scope_user_id = NEW.scope_user_id + AND ( NEW.topic = ANY(ns.topics) + OR EXISTS( + SELECT FROM notification_topic_group_topics ntgt + WHERE ntgt.topic_group = ANY(ns.topic_groups) + AND ntgt.topic = NEW.topic + ) + ) + AND (ns.filter IS NULL OR NEW.data @> ns.filter) + AND + -- A subscriber can be notified if the event is in their scope or if they have permission to the resource. + -- The latter is usually a superset of the former, but the former is trivial to compute so it can help + -- performance to include it. + (NEW.scope_user_id = ns.subscriber_user_id + OR user_has_permission(ns.subscriber_user_id, NEW.resource_id, topic_permission(NEW.topic)) + ) + ) + LOOP + -- Log that this event triggered this subscription. + INSERT INTO notification_providence_log (event_id, subscription_id) + VALUES (the_event_id, the_subscription_id); + + -- Add to the relevant queues. + -- Each delivery method _may_ be triggered by multiple subscriptions, + -- we need ON CONFLICT DO NOTHING. + INSERT INTO notification_webhook_queue (event_id, webhook_id) + SELECT the_event_id, nw.id + FROM notification_webhooks nw + WHERE nw.subscription_id = the_subscription_id + ON CONFLICT DO NOTHING; + + -- If there are any new webhooks to process, trigger workers via LISTEN/NOTIFY + GET DIAGNOSTICS rows_affected = ROW_COUNT; + IF rows_affected > 0 THEN + NOTIFY webhooks; + END IF; + + INSERT INTO notification_email_queue (event_id, email_id) + SELECT the_event_id AS event_id, ne.id + FROM notification_emails ne + WHERE ne.subscription_id = the_subscription_id + ON CONFLICT DO NOTHING; + + -- If there are any new webhooks to process, trigger workers via LISTEN/NOTIFY + GET DIAGNOSTICS rows_affected = ROW_COUNT; + IF rows_affected > 0 THEN + NOTIFY emails; + END IF; + + IF the_subscriber IS NOT NULL THEN + -- Also add the notification to the hub. + -- It's possible it was already added by another subscription for this user, + -- in which case we just carry on. + INSERT INTO notification_hub_entries (event_id, user_id) + VALUES (the_event_id, the_subscriber) + ON CONFLICT DO NOTHING; + END IF; + END LOOP; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + + +ALTER TABLE notification_webhooks + DROP COLUMN subscriber_user_id; + +ALTER TABLE notification_emails + DROP COLUMN subscriber_user_id; + +-- Now we can drop the old tables +DROP TABLE notification_by_webhook; +DROP TABLE notification_by_email; diff --git a/src/Share/Notifications/API.hs b/src/Share/Notifications/API.hs index bb1d5da9..29c395d6 100644 --- a/src/Share/Notifications/API.hs +++ b/src/Share/Notifications/API.hs @@ -5,28 +5,9 @@ module Share.Notifications.API ( API, Routes (..), HubEntriesRoutes (..), - DeliveryMethodRoutes (..), - SubscriptionRoutes (..), - EmailRoutes (..), - WebhookRoutes (..), GetHubEntriesCursor, StatusFilter (..), UpdateHubEntriesRequest (..), - GetSubscriptionsResponse (..), - CreateSubscriptionRequest (..), - CreateSubscriptionResponse (..), - UpdateSubscriptionRequest (..), - SubscriptionDeliveryMethodRoutes (..), - SubscriptionResourceRoutes (..), - AddSubscriptionDeliveryMethodsRequest (..), - RemoveSubscriptionDeliveryMethodsRequest (..), - GetDeliveryMethodsResponse (..), - CreateEmailDeliveryMethodRequest (..), - CreateEmailDeliveryMethodResponse (..), - UpdateEmailDeliveryMethodRequest (..), - CreateWebhookRequest (..), - CreateWebhookResponse (..), - UpdateWebhookRequest (..), ) where @@ -39,20 +20,17 @@ import Data.Text qualified as Text import Data.Time (UTCTime) import Servant import Share.IDs -import Share.Notifications.Types (DeliveryMethodId, HydratedEvent, NotificationDeliveryMethod, NotificationHubEntry, NotificationStatus, NotificationSubscription, NotificationTopic, NotificationTopicGroup, SubscriptionFilter) +import Share.Notifications.Types (HydratedEvent, NotificationHubEntry, NotificationStatus) import Share.OAuth.Session (AuthenticatedUserId) import Share.Prelude import Share.Utils.API (Cursor, Paged) -import Share.Utils.URI (URIParam) import Share.Web.Share.DisplayInfo.Types (UnifiedDisplayInfo) type API = NamedRoutes Routes data Routes mode = Routes - { hubRoutes :: mode :- "hub" :> NamedRoutes HubEntriesRoutes, - deliveryMethodRoutes :: mode :- "delivery-methods" :> NamedRoutes DeliveryMethodRoutes, - subscriptionsRoutes :: mode :- "subscriptions" :> NamedRoutes SubscriptionRoutes + { hubRoutes :: mode :- "hub" :> NamedRoutes HubEntriesRoutes } deriving stock (Generic) @@ -63,167 +41,6 @@ data HubEntriesRoutes mode } deriving stock (Generic) -data DeliveryMethodRoutes mode - = DeliveryMethodRoutes - { getDeliveryMethodsEndpoint :: mode :- GetDeliveryMethodsEndpoint, - emailDeliveryRoutes :: mode :- "emails" :> NamedRoutes EmailRoutes, - webhookDeliveryRoutes :: mode :- "webhooks" :> NamedRoutes WebhookRoutes - } - deriving stock (Generic) - -data SubscriptionRoutes mode - = SubscriptionRoutes - { getSubscriptionsEndpoint :: mode :- GetSubscriptionsEndpoint, - createSubscriptionEndpoint :: mode :- CreateSubscriptionEndpoint, - deleteSubscriptionEndpoint :: mode :- DeleteSubscriptionEndpoint, - updateSubscriptionEndpoint :: mode :- UpdateSubscriptionEndpoint, - subscriptionResourceRoutes :: mode :- Capture "subscriptionId" NotificationSubscriptionId :> NamedRoutes SubscriptionResourceRoutes - } - deriving stock (Generic) - -data SubscriptionResourceRoutes mode - = SubscriptionResourceRoutes - { subscriptionDeliveryMethodRoutes :: mode :- "delivery-methods" :> NamedRoutes SubscriptionDeliveryMethodRoutes - } - deriving stock (Generic) - -data SubscriptionDeliveryMethodRoutes mode - = SubscriptionDeliveryMethodRoutes - { getSubscriptionDeliveryMethodsEndpoint :: mode :- GetDeliveryMethodsEndpoint, - addSubscriptionDeliveryMethodsEndpoint :: mode :- "add" :> AddSubscriptionDeliveryMethodsEndpoint, - removeSubscriptionDeliveryMethodsEndpoint :: mode :- "remove" :> RemoveSubscriptionDeliveryMethodsEndpoint - } - deriving stock (Generic) - -data EmailRoutes mode - = EmailRoutes - { createEmailDeliveryMethodEndpoint :: mode :- CreateEmailDeliveryMethodEndpoint, - deleteEmailDeliveryMethodEndpoint :: mode :- DeleteEmailDeliveryMethodEndpoint, - updateEmailDeliveryMethodEndpoint :: mode :- UpdateEmailDeliveryMethodEndpoint - } - deriving stock (Generic) - -data WebhookRoutes mode - = WebhookRoutes - { createWebhookEndpoint :: mode :- CreateWebhookEndpoint, - deleteWebhookEndpoint :: mode :- DeleteWebhookEndpoint, - updateWebhookEndpoint :: mode :- UpdateWebhookEndpoint - } - deriving stock (Generic) - -type GetSubscriptionsEndpoint = - AuthenticatedUserId - :> Get '[JSON] GetSubscriptionsResponse - -data GetSubscriptionsResponse - = GetSubscriptionsResponse - { subscriptions :: [NotificationSubscription NotificationSubscriptionId] - } - -instance ToJSON GetSubscriptionsResponse where - toJSON GetSubscriptionsResponse {subscriptions} = - object ["subscriptions" .= subscriptions] - -instance FromJSON GetSubscriptionsResponse where - parseJSON = withObject "GetSubscriptionsResponse" $ \o -> do - subscriptions <- o .: "subscriptions" - pure GetSubscriptionsResponse {subscriptions} - -type CreateSubscriptionEndpoint = - AuthenticatedUserId - :> ReqBody '[JSON] CreateSubscriptionRequest - :> Post '[JSON] CreateSubscriptionResponse - -data CreateSubscriptionRequest - = CreateSubscriptionRequest - { subscriptionScope :: UserHandle, - subscriptionTopics :: Set NotificationTopic, - subscriptionTopicGroups :: Set NotificationTopicGroup, - subscriptionFilter :: Maybe SubscriptionFilter - } - -instance ToJSON CreateSubscriptionRequest where - toJSON CreateSubscriptionRequest {subscriptionScope, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} = - object - [ "scope" .= subscriptionScope, - "topics" .= subscriptionTopics, - "topicGroups" .= subscriptionTopicGroups, - "filter" .= subscriptionFilter - ] - -instance FromJSON CreateSubscriptionRequest where - parseJSON = withObject "CreateSubscriptionRequest" $ \o -> do - subscriptionScope <- o .: "scope" - subscriptionTopics <- o .: "topics" - subscriptionTopicGroups <- o .: "topicGroups" - subscriptionFilter <- o .:? "filter" - pure CreateSubscriptionRequest {subscriptionScope, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} - -data CreateSubscriptionResponse - = CreateSubscriptionResponse - { subscription :: NotificationSubscription NotificationSubscriptionId - } - -instance ToJSON CreateSubscriptionResponse where - toJSON CreateSubscriptionResponse {subscription} = - object ["subscription" .= subscription] - -instance FromJSON CreateSubscriptionResponse where - parseJSON = withObject "CreateSubscriptionResponse" $ \o -> do - subscription <- o .: "subscription" - pure CreateSubscriptionResponse {subscription} - -type DeleteSubscriptionEndpoint = - AuthenticatedUserId - :> Capture "subscription_id" NotificationSubscriptionId - :> Delete '[JSON] () - -type UpdateSubscriptionEndpoint = - AuthenticatedUserId - :> Capture "subscription_id" NotificationSubscriptionId - :> ReqBody '[JSON] UpdateSubscriptionRequest - :> Patch '[JSON] CreateSubscriptionResponse - -data UpdateSubscriptionRequest - = UpdateSubscriptionRequest - { subscriptionTopics :: Maybe (Set NotificationTopic), - subscriptionTopicGroups :: Maybe (Set NotificationTopicGroup), - subscriptionFilter :: Maybe SubscriptionFilter - } - -instance ToJSON UpdateSubscriptionRequest where - toJSON UpdateSubscriptionRequest {subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} = - object - [ "topics" .= subscriptionTopics, - "topicGroups" .= subscriptionTopicGroups, - "filter" .= subscriptionFilter - ] - -instance FromJSON UpdateSubscriptionRequest where - parseJSON = withObject "UpdateSubscriptionRequest" $ \o -> do - subscriptionTopics <- o .:? "topics" - subscriptionTopicGroups <- o .:? "topicGroups" - subscriptionFilter <- o .:? "filter" - pure UpdateSubscriptionRequest {subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} - -type GetDeliveryMethodsEndpoint = - AuthenticatedUserId - :> Get '[JSON] GetDeliveryMethodsResponse - -data GetDeliveryMethodsResponse - = GetDeliveryMethodsResponse - { deliveryMethods :: [NotificationDeliveryMethod] - } - -instance ToJSON GetDeliveryMethodsResponse where - toJSON GetDeliveryMethodsResponse {deliveryMethods} = - object ["deliveryMethods" .= deliveryMethods] - -instance FromJSON GetDeliveryMethodsResponse where - parseJSON = withObject "GetDeliveryMethodsResponse" $ \o -> do - deliveryMethods <- o .: "deliveryMethods" - pure GetDeliveryMethodsResponse {deliveryMethods} - newtype StatusFilter = StatusFilter { getStatusFilter :: NESet NotificationStatus } @@ -285,161 +102,3 @@ instance FromJSON UpdateHubEntriesRequest where notificationStatus <- o .: "status" notificationIds <- o .: "notificationIds" pure UpdateHubEntriesRequest {notificationStatus, notificationIds} - -type CreateEmailDeliveryMethodEndpoint = - AuthenticatedUserId - :> ReqBody '[JSON] CreateEmailDeliveryMethodRequest - :> Post '[JSON] CreateEmailDeliveryMethodResponse - -data CreateEmailDeliveryMethodRequest - = CreateEmailDeliveryMethodRequest - { email :: Email - } - -instance ToJSON CreateEmailDeliveryMethodRequest where - toJSON CreateEmailDeliveryMethodRequest {email} = - object ["email" .= email] - -instance FromJSON CreateEmailDeliveryMethodRequest where - parseJSON = withObject "CreateEmailDeliveryMethodRequest" $ \o -> do - email <- o .: "email" - pure CreateEmailDeliveryMethodRequest {email} - -data CreateEmailDeliveryMethodResponse - = CreateEmailDeliveryMethodResponse - { emailDeliveryMethodId :: NotificationEmailDeliveryMethodId - } - -instance ToJSON CreateEmailDeliveryMethodResponse where - toJSON CreateEmailDeliveryMethodResponse {emailDeliveryMethodId} = - object ["emailDeliveryMethodId" .= emailDeliveryMethodId] - -instance FromJSON CreateEmailDeliveryMethodResponse where - parseJSON = withObject "CreateEmailDeliveryMethodResponse" $ \o -> do - emailDeliveryMethodId <- o .: "emailDeliveryMethodId" - pure CreateEmailDeliveryMethodResponse {emailDeliveryMethodId} - -type DeleteEmailDeliveryMethodEndpoint = - AuthenticatedUserId - :> Capture "emailDeliveryMethodId" NotificationEmailDeliveryMethodId - :> Delete '[JSON] () - -type UpdateEmailDeliveryMethodEndpoint = - AuthenticatedUserId - :> Capture "emailDeliveryMethodId" NotificationEmailDeliveryMethodId - :> ReqBody '[JSON] UpdateEmailDeliveryMethodRequest - :> Patch '[JSON] () - -data UpdateEmailDeliveryMethodRequest - = UpdateEmailDeliveryMethodRequest - { email :: Email - } - -instance ToJSON UpdateEmailDeliveryMethodRequest where - toJSON UpdateEmailDeliveryMethodRequest {email} = - object ["email" .= email] - -instance FromJSON UpdateEmailDeliveryMethodRequest where - parseJSON = withObject "UpdateEmailDeliveryMethodRequest" $ \o -> do - email <- o .: "email" - pure UpdateEmailDeliveryMethodRequest {email} - -type CreateWebhookEndpoint = - AuthenticatedUserId - :> ReqBody '[JSON] CreateWebhookRequest - :> Post '[JSON] CreateWebhookResponse - -data CreateWebhookRequest - = CreateWebhookRequest - { url :: URIParam, - name :: Text - } - -instance ToJSON CreateWebhookRequest where - toJSON CreateWebhookRequest {url, name} = - object ["url" .= url, "name" .= name] - -instance FromJSON CreateWebhookRequest where - parseJSON = withObject "CreateWebhookRequest" $ \o -> do - url <- o .: "url" - name <- o .: "name" - pure CreateWebhookRequest {url, name} - -data CreateWebhookResponse - = CreateWebhookResponse - { webhookId :: NotificationWebhookId - } - -instance ToJSON CreateWebhookResponse where - toJSON CreateWebhookResponse {webhookId} = - object ["webhookId" .= webhookId] - -instance FromJSON CreateWebhookResponse where - parseJSON = withObject "CreateWebhookResponse" $ \o -> do - webhookId <- o .: "webhookId" - pure CreateWebhookResponse {webhookId} - -type DeleteWebhookEndpoint = - AuthenticatedUserId - :> Capture "webhookId" NotificationWebhookId - :> Delete '[JSON] () - -type UpdateWebhookEndpoint = - AuthenticatedUserId - :> Capture "webhookId" NotificationWebhookId - :> ReqBody '[JSON] UpdateWebhookRequest - :> Patch '[JSON] () - -data UpdateWebhookRequest - = UpdateWebhookRequest - { url :: URIParam - } - -instance ToJSON UpdateWebhookRequest where - toJSON UpdateWebhookRequest {url} = - object ["url" .= url] - -instance FromJSON UpdateWebhookRequest where - parseJSON = withObject "UpdateWebhookRequest" $ \o -> do - url <- o .: "url" - pure UpdateWebhookRequest {url} - -type AddSubscriptionDeliveryMethodsEndpoint = - AuthenticatedUserId - :> ReqBody '[JSON] AddSubscriptionDeliveryMethodsRequest - :> Post '[JSON] () - -type RemoveSubscriptionDeliveryMethodsEndpoint = - AuthenticatedUserId - :> ReqBody '[JSON] RemoveSubscriptionDeliveryMethodsRequest - :> Delete '[JSON] () - -data AddSubscriptionDeliveryMethodsRequest - = AddSubscriptionDeliveryMethodsRequest - { deliveryMethods :: NESet DeliveryMethodId - } - deriving stock (Show, Eq, Ord) - -instance ToJSON AddSubscriptionDeliveryMethodsRequest where - toJSON AddSubscriptionDeliveryMethodsRequest {deliveryMethods} = - object ["deliveryMethods" .= deliveryMethods] - -instance FromJSON AddSubscriptionDeliveryMethodsRequest where - parseJSON = withObject "AddSubscriptionDeliveryMethodsRequest" $ \o -> do - deliveryMethods <- o .: "deliveryMethods" - pure AddSubscriptionDeliveryMethodsRequest {deliveryMethods} - -data RemoveSubscriptionDeliveryMethodsRequest - = RemoveSubscriptionDeliveryMethodsRequest - { deliveryMethods :: NESet DeliveryMethodId - } - deriving stock (Show, Eq, Ord) - -instance ToJSON RemoveSubscriptionDeliveryMethodsRequest where - toJSON RemoveSubscriptionDeliveryMethodsRequest {deliveryMethods} = - object ["deliveryMethods" .= deliveryMethods] - -instance FromJSON RemoveSubscriptionDeliveryMethodsRequest where - parseJSON = withObject "RemoveSubscriptionDeliveryMethodsRequest" $ \o -> do - deliveryMethods <- o .: "deliveryMethods" - pure RemoveSubscriptionDeliveryMethodsRequest {deliveryMethods} diff --git a/src/Share/Notifications/Impl.hs b/src/Share/Notifications/Impl.hs index c911493a..e8113fac 100644 --- a/src/Share/Notifications/Impl.hs +++ b/src/Share/Notifications/Impl.hs @@ -24,60 +24,10 @@ hubRoutes userHandle = updateHubEntriesEndpoint = updateHubEntriesEndpoint userHandle } -deliveryMethodRoutes :: UserHandle -> API.DeliveryMethodRoutes (AsServerT WebApp) -deliveryMethodRoutes userHandle = - API.DeliveryMethodRoutes - { getDeliveryMethodsEndpoint = getDeliveryMethodsEndpoint userHandle, - emailDeliveryRoutes = emailDeliveryRoutes userHandle, - webhookDeliveryRoutes = webhookDeliveryRoutes userHandle - } - -subscriptionsRoutes :: UserHandle -> API.SubscriptionRoutes (AsServerT WebApp) -subscriptionsRoutes userHandle = - API.SubscriptionRoutes - { getSubscriptionsEndpoint = getSubscriptionsEndpoint userHandle, - createSubscriptionEndpoint = createSubscriptionEndpoint userHandle, - deleteSubscriptionEndpoint = deleteSubscriptionEndpoint userHandle, - updateSubscriptionEndpoint = updateSubscriptionEndpoint userHandle, - subscriptionResourceRoutes = subscriptionResourceRoutes userHandle - } - -subscriptionResourceRoutes :: UserHandle -> NotificationSubscriptionId -> API.SubscriptionResourceRoutes (AsServerT WebApp) -subscriptionResourceRoutes handle subscriptionId = - API.SubscriptionResourceRoutes - { subscriptionDeliveryMethodRoutes = subscriptionDeliveryMethodRoutes handle subscriptionId - } - -subscriptionDeliveryMethodRoutes :: UserHandle -> NotificationSubscriptionId -> API.SubscriptionDeliveryMethodRoutes (AsServerT WebApp) -subscriptionDeliveryMethodRoutes handle subscriptionId = - API.SubscriptionDeliveryMethodRoutes - { getSubscriptionDeliveryMethodsEndpoint = getSubscriptionDeliveryMethodsEndpoint handle subscriptionId, - addSubscriptionDeliveryMethodsEndpoint = addSubscriptionDeliveryMethodsEndpoint handle subscriptionId, - removeSubscriptionDeliveryMethodsEndpoint = removeSubscriptionDeliveryMethodsEndpoint handle subscriptionId - } - -emailDeliveryRoutes :: UserHandle -> API.EmailRoutes (AsServerT WebApp) -emailDeliveryRoutes userHandle = - API.EmailRoutes - { createEmailDeliveryMethodEndpoint = createEmailDeliveryMethodEndpoint userHandle, - deleteEmailDeliveryMethodEndpoint = deleteEmailDeliveryMethodEndpoint userHandle, - updateEmailDeliveryMethodEndpoint = updateEmailDeliveryMethodEndpoint userHandle - } - -webhookDeliveryRoutes :: UserHandle -> API.WebhookRoutes (AsServerT WebApp) -webhookDeliveryRoutes userHandle = - API.WebhookRoutes - { createWebhookEndpoint = createWebhookEndpoint userHandle, - deleteWebhookEndpoint = deleteWebhookEndpoint userHandle, - updateWebhookEndpoint = updateWebhookEndpoint userHandle - } - server :: UserHandle -> ServerT API.API WebApp server userHandle = API.Routes - { hubRoutes = hubRoutes userHandle, - deliveryMethodRoutes = deliveryMethodRoutes userHandle, - subscriptionsRoutes = subscriptionsRoutes userHandle + { hubRoutes = hubRoutes userHandle } getHubEntriesEndpoint :: @@ -101,110 +51,3 @@ updateHubEntriesEndpoint userHandle callerUserId API.UpdateHubEntriesRequest {no _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkNotificationsUpdate callerUserId notificationUserId PG.runTransaction $ NotificationQ.updateNotificationHubEntries notificationIds notificationStatus pure () - -getDeliveryMethodsEndpoint :: UserHandle -> UserId -> WebApp API.GetDeliveryMethodsResponse -getDeliveryMethodsEndpoint userHandle callerUserId = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsView callerUserId notificationUserId - deliveryMethods <- NotifOps.listNotificationDeliveryMethods notificationUserId Nothing - pure $ API.GetDeliveryMethodsResponse {deliveryMethods} - -createEmailDeliveryMethodEndpoint :: UserHandle -> UserId -> API.CreateEmailDeliveryMethodRequest -> WebApp API.CreateEmailDeliveryMethodResponse -createEmailDeliveryMethodEndpoint userHandle callerUserId API.CreateEmailDeliveryMethodRequest {email} = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - emailDeliveryMethodId <- PG.runTransaction $ NotificationQ.createEmailDeliveryMethod notificationUserId email - pure $ API.CreateEmailDeliveryMethodResponse {emailDeliveryMethodId} - -deleteEmailDeliveryMethodEndpoint :: UserHandle -> UserId -> NotificationEmailDeliveryMethodId -> WebApp () -deleteEmailDeliveryMethodEndpoint userHandle callerUserId emailDeliveryMethodId = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - PG.runTransaction $ NotificationQ.deleteEmailDeliveryMethod notificationUserId emailDeliveryMethodId - pure () - -updateEmailDeliveryMethodEndpoint :: UserHandle -> UserId -> NotificationEmailDeliveryMethodId -> API.UpdateEmailDeliveryMethodRequest -> WebApp () -updateEmailDeliveryMethodEndpoint userHandle callerUserId emailDeliveryMethodId API.UpdateEmailDeliveryMethodRequest {email} = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - PG.runTransaction $ NotificationQ.updateEmailDeliveryMethod notificationUserId emailDeliveryMethodId email - pure () - -getSubscriptionDeliveryMethodsEndpoint :: UserHandle -> NotificationSubscriptionId -> UserId -> WebApp API.GetDeliveryMethodsResponse -getSubscriptionDeliveryMethodsEndpoint userHandle subscriptionId callerUserId = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsView callerUserId notificationUserId - deliveryMethods <- NotifOps.listNotificationDeliveryMethods notificationUserId (Just subscriptionId) - pure $ API.GetDeliveryMethodsResponse {deliveryMethods} - -addSubscriptionDeliveryMethodsEndpoint :: UserHandle -> NotificationSubscriptionId -> UserId -> API.AddSubscriptionDeliveryMethodsRequest -> WebApp () -addSubscriptionDeliveryMethodsEndpoint userHandle subscriptionId callerUserId API.AddSubscriptionDeliveryMethodsRequest {deliveryMethods} = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - PG.runTransaction $ NotificationQ.addSubscriptionDeliveryMethods notificationUserId subscriptionId deliveryMethods - pure () - -removeSubscriptionDeliveryMethodsEndpoint :: UserHandle -> NotificationSubscriptionId -> UserId -> API.RemoveSubscriptionDeliveryMethodsRequest -> WebApp () -removeSubscriptionDeliveryMethodsEndpoint userHandle subscriptionId callerUserId API.RemoveSubscriptionDeliveryMethodsRequest {deliveryMethods} = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - PG.runTransaction $ NotificationQ.removeSubscriptionDeliveryMethods notificationUserId subscriptionId deliveryMethods - pure () - -createWebhookEndpoint :: UserHandle -> UserId -> API.CreateWebhookRequest -> WebApp API.CreateWebhookResponse -createWebhookEndpoint userHandle callerUserId API.CreateWebhookRequest {url, name = webhookName} = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - webhookId <- NotifOps.createWebhookDeliveryMethod notificationUserId url webhookName - pure $ API.CreateWebhookResponse {webhookId} - -deleteWebhookEndpoint :: UserHandle -> UserId -> NotificationWebhookId -> WebApp () -deleteWebhookEndpoint userHandle callerUserId webhookDeliveryMethodId = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - NotifOps.deleteWebhookDeliveryMethod notificationUserId webhookDeliveryMethodId - pure () - -updateWebhookEndpoint :: UserHandle -> UserId -> NotificationWebhookId -> API.UpdateWebhookRequest -> WebApp () -updateWebhookEndpoint userHandle callerUserId webhookDeliveryMethodId API.UpdateWebhookRequest {url} = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkDeliveryMethodsManage callerUserId notificationUserId - NotifOps.updateWebhookDeliveryMethod notificationUserId webhookDeliveryMethodId url - pure () - -getSubscriptionsEndpoint :: UserHandle -> UserId -> WebApp API.GetSubscriptionsResponse -getSubscriptionsEndpoint userHandle callerUserId = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkSubscriptionsView callerUserId notificationUserId - subscriptions <- PG.runTransaction $ NotificationQ.listNotificationSubscriptions notificationUserId - pure $ API.GetSubscriptionsResponse {subscriptions} - -createSubscriptionEndpoint :: UserHandle -> UserId -> API.CreateSubscriptionRequest -> WebApp API.CreateSubscriptionResponse -createSubscriptionEndpoint subscriberHandle callerUserId API.CreateSubscriptionRequest {subscriptionScope, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} = do - User {user_id = subscriberUserId} <- UserQ.expectUserByHandle subscriberHandle - User {user_id = scopeUserId} <- UserQ.expectUserByHandle subscriptionScope - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkSubscriptionsManage callerUserId subscriberUserId - -- NOTE: We allow creating any sort of notification subscription, even for things a user - -- doesn't have access to, but the notification system will only actually create notifications if the caller has access to - -- the resource of a given event for the permission associated to that topic via the - -- 'topic_permission' SQL function. - subscription <- PG.runTransaction $ do - subscriptionId <- NotificationQ.createNotificationSubscription subscriberUserId scopeUserId subscriptionTopics subscriptionTopicGroups subscriptionFilter - NotificationQ.getNotificationSubscription subscriberUserId subscriptionId - pure $ API.CreateSubscriptionResponse {subscription} - -deleteSubscriptionEndpoint :: UserHandle -> UserId -> NotificationSubscriptionId -> WebApp () -deleteSubscriptionEndpoint userHandle callerUserId subscriptionId = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkSubscriptionsManage callerUserId notificationUserId - PG.runTransaction $ NotificationQ.deleteNotificationSubscription notificationUserId subscriptionId - pure () - -updateSubscriptionEndpoint :: UserHandle -> UserId -> NotificationSubscriptionId -> API.UpdateSubscriptionRequest -> WebApp API.CreateSubscriptionResponse -updateSubscriptionEndpoint userHandle callerUserId subscriptionId API.UpdateSubscriptionRequest {subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} = do - User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkSubscriptionsManage callerUserId notificationUserId - subscription <- PG.runTransaction $ do - NotificationQ.updateNotificationSubscription notificationUserId subscriptionId subscriptionTopics subscriptionTopicGroups subscriptionFilter - NotificationQ.getNotificationSubscription notificationUserId subscriptionId - pure $ API.CreateSubscriptionResponse {subscription} diff --git a/src/Share/Notifications/Ops.hs b/src/Share/Notifications/Ops.hs index ad378e1a..0f6c5faa 100644 --- a/src/Share/Notifications/Ops.hs +++ b/src/Share/Notifications/Ops.hs @@ -1,23 +1,37 @@ module Share.Notifications.Ops ( listNotificationDeliveryMethods, - createWebhookDeliveryMethod, + addWebhookDeliveryMethod, updateWebhookDeliveryMethod, deleteWebhookDeliveryMethod, + listProjectWebhooks, + createProjectWebhook, + deleteProjectWebhook, + updateProjectWebhook, + expectProjectWebhook, hydrateEvent, ) where +import Control.Lens +import Data.Set qualified as Set +import Data.Set.NonEmpty qualified as NESet +import Share.App (AppM) import Share.IDs import Share.Notifications.Queries qualified as NotifQ import Share.Notifications.Types import Share.Notifications.Webhooks.Secrets (WebhookConfig (..)) import Share.Notifications.Webhooks.Secrets qualified as WebhookSecrets +import Share.Notifications.Webhooks.Secrets qualified as Webhooks import Share.Postgres qualified as PG +import Share.Postgres.Queries qualified as Q import Share.Prelude +import Share.Project (Project (..)) import Share.Utils.URI (URIParam (..)) import Share.Web.App (WebApp) import Share.Web.Errors (respondError) +import Share.Web.Share.Projects.Types (ProjectWebhook (..), ProjectWebhookTopics (..)) import Share.Web.UI.Links qualified as Links +import UnliftIO qualified listNotificationDeliveryMethods :: UserId -> Maybe NotificationSubscriptionId -> WebApp [NotificationDeliveryMethod] listNotificationDeliveryMethods userId maySubscriptionId = do @@ -33,34 +47,144 @@ listNotificationDeliveryMethods userId maySubscriptionId = do pure $ (EmailDeliveryMethod <$> emailDeliveryMethods) <> (WebhookDeliveryMethod <$> webhookDeliveryMethods) -createWebhookDeliveryMethod :: UserId -> URIParam -> Text -> WebApp NotificationWebhookId -createWebhookDeliveryMethod userId uriParam webhookName = do - -- Note that we can't be completely transactional between postgres and vault here. - webhookId <- PG.runTransaction do - NotifQ.createWebhookDeliveryMethod userId webhookName +addWebhookDeliveryMethod :: URIParam -> Text -> NotificationSubscriptionId -> (AppM r (Either Webhooks.WebhookSecretError ()) -> IO (Either Webhooks.WebhookSecretError ())) -> PG.Transaction Webhooks.WebhookSecretError NotificationWebhookId +addWebhookDeliveryMethod uriParam webhookName notificationSubscriptionId runInIO = do let webhookConfig = WebhookConfig {uri = uriParam} - WebhookSecrets.putWebhookConfig webhookId webhookConfig + -- Note that we can't be completely transactional between postgres and vault here. + webhookId <- NotifQ.createWebhookDeliveryMethod webhookName notificationSubscriptionId + -- We run this inside the transaction such that, if it fails, the transaction + -- will be rolled back. + -- + -- In the case where it succeeds, but the transaction fails to commit (which is unlikely) + -- we may have a dangling secret in Vault, which isn't ideal, but is not so bad. + PG.transactionUnsafeIO (runInIO $ WebhookSecrets.putWebhookConfig webhookId webhookConfig) >>= \case + Left err -> do + throwError err + Right _ -> pure () pure webhookId -updateWebhookDeliveryMethod :: UserId -> NotificationWebhookId -> URIParam -> WebApp () -updateWebhookDeliveryMethod notificationUser webhookDeliveryMethodId url = do +deleteWebhookDeliveryMethod :: SubscriptionOwner -> NotificationWebhookId -> WebApp () +deleteWebhookDeliveryMethod owner webhookDeliveryMethodId = do + let ownerFilter = case owner of + UserSubscriptionOwner userId -> [PG.sql| ns.subscriber_user_id = #{userId} |] + ProjectSubscriptionOwner projectId -> [PG.sql| ns.subscriber_project_id = #{projectId} |] isValid <- PG.runTransaction $ do PG.queryExpect1Col [PG.sql| SELECT EXISTS( SELECT FROM notification_webhooks nw + JOIN notification_subscriptions ns + ON nw.subscription_id = ns.id WHERE nw.id = #{webhookDeliveryMethodId} - AND nw.subscriber_user_id = #{notificationUser} + AND ^{ownerFilter} ) |] when isValid $ do - -- Update the webhook config in Vault - WebhookSecrets.putWebhookConfig webhookDeliveryMethodId (WebhookConfig url) >>= \case + -- Delete the webhook config in Vault + WebhookSecrets.deleteWebhookConfig webhookDeliveryMethodId >>= \case Left err -> respondError err - Right _ -> pure () + Right _ -> do + PG.runTransaction $ do + NotifQ.deleteWebhookDeliveryMethod owner webhookDeliveryMethodId + +hydrateEvent :: HydratedEventPayload -> PG.Transaction e HydratedEvent +hydrateEvent hydratedEventPayload = do + hydratedEventLink <- Links.notificationLink hydratedEventPayload + pure $ HydratedEvent {hydratedEventPayload, hydratedEventLink} + +-- | We provide a wrapper layer on top of notification subscriptions and webhooks +-- to make the frontend experience a bit more intuitive. +listProjectWebhooks :: ProjectId -> WebApp [ProjectWebhook] +listProjectWebhooks projectId = do + projectWebhooks <- PG.runTransaction $ do NotifQ.listProjectWebhooks projectId + results <- + projectWebhooks + & asListOf (traversed . _1) %%~ \webhookIds -> + do + UnliftIO.pooledForConcurrently webhookIds \webhookId -> do + Webhooks.fetchWebhookConfig webhookId >>= \case + Left err -> respondError err + Right (WebhookConfig {uri = URIParam uri}) -> do + pure $ (NotificationWebhookConfig webhookId uri) + let webhooks = + results <&> \(NotificationWebhookConfig {webhookDeliveryUrl = url}, _name, NotificationSubscription {subscriptionTopics, subscriptionTopicGroups, subscriptionId, subscriptionCreatedAt, subscriptionUpdatedAt}) -> + let webhookTopics = case (Set.toList subscriptionTopicGroups, NESet.nonEmptySet subscriptionTopics) of + ([], Just topics) -> SelectedTopics topics + _ -> AllTopicsInProject + in ProjectWebhook + { projectWebhookUri = URIParam url, + projectWebhookTopics = webhookTopics, + projectWebhookNotificationSubscriptionId = subscriptionId, + projectWebhookCreatedAt = subscriptionCreatedAt, + projectWebhookUpdatedAt = subscriptionUpdatedAt + } + pure webhooks + +createProjectWebhook :: ProjectId -> URIParam -> ProjectWebhookTopics -> WebApp ProjectWebhook +createProjectWebhook projectId uri webhookTopics = do + let (topics, topicGroups) = case webhookTopics of + AllTopicsInProject -> (mempty, Set.singleton AllProjectTopics) + SelectedTopics ts -> (NESet.toSet ts, mempty) + let filter = Nothing + runInIO <- UnliftIO.askRunInIO + subscriptionId <- PG.runTransactionOrRespondError $ do + Project {ownerUserId = projectOwner} <- Q.expectProjectById projectId + subscriptionId <- NotifQ.createNotificationSubscription (ProjectSubscriptionOwner projectId) projectOwner (Just projectId) topics topicGroups filter + addWebhookDeliveryMethod uri "Project Webhook" subscriptionId runInIO + pure subscriptionId + expectProjectWebhook projectId subscriptionId -deleteWebhookDeliveryMethod :: UserId -> NotificationWebhookId -> WebApp () -deleteWebhookDeliveryMethod notificationUser webhookDeliveryMethodId = do +expectProjectWebhook :: ProjectId -> NotificationSubscriptionId -> WebApp ProjectWebhook +expectProjectWebhook projectId subscriptionId = do + (webhookId, _name) <- PG.runTransaction $ do NotifQ.expectProjectWebhook projectId subscriptionId + uri <- + WebhookSecrets.fetchWebhookConfig webhookId >>= \case + Left err -> respondError err + Right (WebhookConfig {uri}) -> pure uri + subscription <- PG.runTransaction $ do NotifQ.expectNotificationSubscription (ProjectSubscriptionOwner projectId) subscriptionId + let subscriptionTopics = case (Set.toList $ subscription.subscriptionTopicGroups, NESet.nonEmptySet subscription.subscriptionTopics) of + ([], Just topics) -> SelectedTopics topics + _ -> AllTopicsInProject + pure $ + ProjectWebhook + { projectWebhookUri = uri, + projectWebhookTopics = subscriptionTopics, + projectWebhookNotificationSubscriptionId = subscription.subscriptionId, + projectWebhookCreatedAt = subscription.subscriptionCreatedAt, + projectWebhookUpdatedAt = subscription.subscriptionUpdatedAt + } + +deleteProjectWebhook :: ProjectId -> NotificationSubscriptionId -> WebApp () +deleteProjectWebhook projectId subscriptionId = do + let owner = ProjectSubscriptionOwner projectId + -- First fetch the webhook id associated with this subscription + webhooks <- PG.runTransaction $ do NotifQ.webhooksForSubscription subscriptionId + -- Next delete the subscription, which cascades to delete the webhook rows. + PG.runTransaction $ do NotifQ.deleteNotificationSubscription owner subscriptionId + -- Now delete the webhook configs in Vault, it's possible that this fails, but + -- it's okay if there are dangling webhooks in vault, we can't be fully transactional here, + -- and it's better than having rows that point to missing secrets. + for_ webhooks \webhookId -> do + deleteWebhookDeliveryMethod owner webhookId + +updateProjectWebhook :: SubscriptionOwner -> NotificationSubscriptionId -> Maybe URIParam -> (Maybe ProjectWebhookTopics) -> WebApp () +updateProjectWebhook subscriptionOwner subscriptionId mayURIUpdate webhookTopics = do + for_ mayURIUpdate \uri -> do + -- First fetch the webhook ids associated with this subscription + webhooks <- PG.runTransaction $ do NotifQ.webhooksForSubscription subscriptionId + for_ webhooks \webhookId -> do + -- Update the webhook config in Vault + WebhookSecrets.putWebhookConfig webhookId (WebhookConfig uri) >>= \case + Left err -> respondError err + Right _ -> pure () + let (topics, topicGroups) = case webhookTopics of + Nothing -> (Nothing, Nothing) + Just AllTopicsInProject -> (Just mempty, Just $ Set.singleton AllProjectTopics) + Just (SelectedTopics ts) -> (Just $ NESet.toSet ts, Just $ mempty) + PG.runTransaction $ NotifQ.updateNotificationSubscription subscriptionOwner subscriptionId topics topicGroups Nothing + +updateWebhookDeliveryMethod :: UserId -> NotificationWebhookId -> URIParam -> WebApp () +updateWebhookDeliveryMethod notificationUser webhookDeliveryMethodId url = do isValid <- PG.runTransaction $ do PG.queryExpect1Col [PG.sql| @@ -71,14 +195,7 @@ deleteWebhookDeliveryMethod notificationUser webhookDeliveryMethodId = do ) |] when isValid $ do - -- Delete the webhook config in Vault - WebhookSecrets.deleteWebhookConfig webhookDeliveryMethodId >>= \case + -- Update the webhook config in Vault + WebhookSecrets.putWebhookConfig webhookDeliveryMethodId (WebhookConfig url) >>= \case Left err -> respondError err - Right _ -> do - PG.runTransaction $ do - NotifQ.deleteWebhookDeliveryMethod notificationUser webhookDeliveryMethodId - -hydrateEvent :: HydratedEventPayload -> PG.Transaction e HydratedEvent -hydrateEvent hydratedEventPayload = do - hydratedEventLink <- Links.notificationLink hydratedEventPayload - pure $ HydratedEvent {hydratedEventPayload, hydratedEventLink} + Right _ -> pure () diff --git a/src/Share/Notifications/Queries.hs b/src/Share/Notifications/Queries.hs index 7394ac24..41b46de1 100644 --- a/src/Share/Notifications/Queries.hs +++ b/src/Share/Notifications/Queries.hs @@ -1,10 +1,10 @@ +{-# LANGUAGE TypeOperators #-} + module Share.Notifications.Queries ( recordEvent, expectEvent, listNotificationHubEntryPayloads, updateNotificationHubEntries, - addSubscriptionDeliveryMethods, - removeSubscriptionDeliveryMethods, listEmailDeliveryMethods, listWebhooks, createEmailDeliveryMethod, @@ -16,11 +16,14 @@ module Share.Notifications.Queries createNotificationSubscription, deleteNotificationSubscription, updateNotificationSubscription, - getNotificationSubscription, + expectNotificationSubscription, hydrateEventPayload, hasUnreadNotifications, updateWatchProjectSubscription, isUserSubscribedToWatchProject, + listProjectWebhooks, + webhooksForSubscription, + expectProjectWebhook, ) where @@ -48,18 +51,18 @@ import Share.Web.Share.DisplayInfo.Queries qualified as DisplayInfoQ import Share.Web.Share.DisplayInfo.Types (UnifiedDisplayInfo) recordEvent :: (QueryA m) => NewNotificationEvent -> m () -recordEvent (NotificationEvent {eventScope, eventData, eventResourceId, eventActor}) = do +recordEvent (NotificationEvent {eventScope, eventData, eventResourceId, eventProjectId, eventActor}) = do execute_ [sql| - INSERT INTO notification_events (topic, scope_user_id, actor_user_id, resource_id, data) - VALUES (#{eventTopic eventData}::notification_topic, #{eventScope}, #{eventActor}, #{eventResourceId}, #{eventData}) + INSERT INTO notification_events (topic, scope_user_id, actor_user_id, resource_id, project_id, data) + VALUES (#{eventTopic eventData}::notification_topic, #{eventScope}, #{eventActor}, #{eventResourceId}, #{eventProjectId}, #{eventData}) |] expectEvent :: (QueryM m) => NotificationEventId -> m PGNotificationEvent expectEvent eventId = do queryExpect1Row @PGNotificationEvent [sql| - SELECT id, occurred_at, scope_user_id, actor_user_id, resource_id, topic, data + SELECT id, occurred_at, scope_user_id, actor_user_id, resource_id, project_id, topic, data FROM notification_events WHERE id = #{eventId} |] @@ -91,7 +94,7 @@ listNotificationHubEntryPayloads notificationUserId mayLimit mayCursor statusFil query limit cursorFilter = queryListRows @(NotificationHubEntry UserId NotificationEventData) [sql| - SELECT hub.id, hub.status, hub.created_at, event.id, event.occurred_at, event.scope_user_id, event.actor_user_id, event.resource_id, event.topic, event.data + SELECT hub.id, hub.status, hub.created_at, event.id, event.occurred_at, event.scope_user_id, event.actor_user_id, event.resource_id, event.project_id, event.topic, event.data FROM notification_hub_entries hub JOIN notification_events event ON hub.event_id = event.id WHERE hub.user_id = #{notificationUserId} @@ -129,70 +132,6 @@ updateNotificationHubEntries hubEntryIds status = do WHERE id IN (SELECT notification_id FROM to_update) |] --- | Note: If a given delivery method belongs to a different subscriber user, it will simply be ignored, --- this should never happen in non-malicious workflows so it's fine to ignore it. -addSubscriptionDeliveryMethods :: UserId -> NotificationSubscriptionId -> NESet DeliveryMethodId -> Transaction e () -addSubscriptionDeliveryMethods subscriberUserId subscriptionId deliveryMethods = do - let (emailIds, webhookIds) = - deliveryMethods & foldMap \case - EmailDeliveryMethodId emailId -> ([emailId], mempty) - WebhookDeliveryMethodId webhookId -> (mempty, [webhookId]) - execute_ - [sql| - WITH email_ids(email_id) AS ( - SELECT * FROM ^{singleColumnTable emailIds} - ) - INSERT INTO notification_by_email (subscription_id, email_id) - SELECT #{subscriptionId}, ei.email_id - FROM email_ids ei - JOIN notification_emails ne ON ei.email_id = ne.id - WHERE ne.subscriber_user_id = #{subscriberUserId} - ON CONFLICT DO NOTHING - |] - execute_ - [sql| - WITH webhook_ids(webhook_id) AS ( - SELECT * FROM ^{singleColumnTable webhookIds} - ) - INSERT INTO notification_by_webhook (subscription_id, webhook_id) - SELECT #{subscriptionId}, wi.webhook_id - FROM webhook_ids wi - JOIN notification_webhooks nw ON wi.webhook_id = nw.id - WHERE nw.subscriber_user_id = #{subscriberUserId} - ON CONFLICT DO NOTHING - |] - -removeSubscriptionDeliveryMethods :: UserId -> NotificationSubscriptionId -> NESet DeliveryMethodId -> Transaction e () -removeSubscriptionDeliveryMethods subscriberUserId subscriptionId deliveryMethods = do - let (emailIds, webhookIds) = - deliveryMethods & foldMap \case - EmailDeliveryMethodId emailId -> ([emailId], mempty) - WebhookDeliveryMethodId webhookId -> (mempty, [webhookId]) - execute_ - [sql| - DELETE FROM notification_by_email - WHERE subscription_id = #{subscriptionId} - AND email_id IN (SELECT * FROM ^{singleColumnTable emailIds}) - AND EXISTS ( - SELECT 1 - FROM notification_emails ne - WHERE ne.id = email_id - AND ne.subscriber_user_id = #{subscriberUserId} - ) - |] - execute_ - [sql| - DELETE FROM notification_by_webhook - WHERE subscription_id = #{subscriptionId} - AND webhook_id IN (SELECT * FROM ^{singleColumnTable webhookIds}) - AND EXISTS ( - SELECT 1 - FROM notification_webhooks nw - WHERE nw.id = webhook_id - AND nw.subscriber_user_id = #{subscriberUserId} - ) - |] - listEmailDeliveryMethods :: (QueryA m) => UserId -> Maybe NotificationSubscriptionId -> m [NotificationEmailDeliveryConfig] listEmailDeliveryMethods userId maySubscriptionId = do queryListRows @@ -201,11 +140,7 @@ listEmailDeliveryMethods userId maySubscriptionId = do FROM notification_emails ne WHERE ne.subscriber_user_id = #{userId} AND (#{maySubscriptionId} IS NULL - OR EXISTS( - SELECT FROM notification_by_email nbe - WHERE nbe.subscription_id = #{maySubscriptionId} - AND nbe.email_id = ne.id - ) + OR ne.subscription_id = #{maySubscriptionId} ) ORDER BY ne.email |] @@ -218,11 +153,7 @@ listWebhooks userId maySubscriptionId = do FROM notification_webhooks nw WHERE nw.subscriber_user_id = #{userId} AND (#{maySubscriptionId} IS NULL - OR EXISTS( - SELECT FROM notification_by_webhook nbw - WHERE nbw.subscription_id = #{maySubscriptionId} - AND nbw.webhook_id = nw.id - ) + OR nw.subscription_id = #{maySubscriptionId} ) ORDER BY nw.created_at |] @@ -266,54 +197,79 @@ deleteEmailDeliveryMethod notificationUserId emailDeliveryMethodId = do AND subscriber_user_id = #{notificationUserId} |] -createWebhookDeliveryMethod :: UserId -> Text -> Transaction e NotificationWebhookId -createWebhookDeliveryMethod userId name = do +createWebhookDeliveryMethod :: Text -> NotificationSubscriptionId -> Transaction e NotificationWebhookId +createWebhookDeliveryMethod name subscriptionId = do queryExpect1Col [sql| - INSERT INTO notification_webhooks (subscriber_user_id, name) - VALUES (#{userId}, #{name}) + INSERT INTO notification_webhooks (name, subscription_id) + VALUES (#{name}, #{subscriptionId}) RETURNING id |] -deleteWebhookDeliveryMethod :: UserId -> NotificationWebhookId -> Transaction e () -deleteWebhookDeliveryMethod notificationUserId webhookDeliveryMethodId = do +deleteWebhookDeliveryMethod :: SubscriptionOwner -> NotificationWebhookId -> Transaction e () +deleteWebhookDeliveryMethod owner webhookDeliveryMethodId = do + let ownerFilter = case owner of + UserSubscriptionOwner userId -> [sql| nw.subscriber_user_id = #{userId} |] + ProjectSubscriptionOwner projectId -> [sql| nw.subscriber_project_id = #{projectId} |] execute_ [sql| DELETE FROM notification_webhooks + USING notification_subscriptions ns WHERE id = #{webhookDeliveryMethodId} - AND subscriber_user_id = #{notificationUserId} + AND ns.id = nw.subscription_id + AND ^{ownerFilter} |] listNotificationSubscriptions :: UserId -> Transaction e [NotificationSubscription NotificationSubscriptionId] listNotificationSubscriptions subscriberUserId = do queryListRows [sql| - SELECT ns.id, ns.scope_user_id, ns.topics, ns.topic_groups, ns.filter + SELECT + ns.id, + ns.scope_user_id, + ns.scope_project_id, + ns.subscriber_user_id, + ns.subscriber_project_id, + ns.topics, + ns.topic_groups, + ns.filter, + ns.created_at, + ns.updated_at FROM notification_subscriptions ns WHERE ns.subscriber_user_id = #{subscriberUserId} ORDER BY ns.created_at DESC |] -createNotificationSubscription :: UserId -> UserId -> Set NotificationTopic -> Set NotificationTopicGroup -> Maybe SubscriptionFilter -> Transaction e NotificationSubscriptionId -createNotificationSubscription subscriberUserId subscriptionScope subscriptionTopics subscriptionTopicGroups subscriptionFilter = do +createNotificationSubscription :: SubscriptionOwner -> UserId -> Maybe ProjectId -> Set NotificationTopic -> Set NotificationTopicGroup -> Maybe SubscriptionFilter -> Transaction e NotificationSubscriptionId +createNotificationSubscription owner subscriptionScope subscriptionProjectId subscriptionTopics subscriptionTopicGroups subscriptionFilter = do + let (subscriberUserId, subscriberProjectId) = case owner of + ProjectSubscriptionOwner projectId -> (Nothing, Just projectId) + UserSubscriptionOwner userId -> (Just userId, Nothing) queryExpect1Col [sql| - INSERT INTO notification_subscriptions (subscriber_user_id, scope_user_id, topics, topic_groups, filter) - VALUES (#{subscriberUserId}, #{subscriptionScope}, #{Foldable.toList subscriptionTopics}::notification_topic[], #{Foldable.toList subscriptionTopicGroups}::notification_topic_group[], #{subscriptionFilter}) + INSERT INTO notification_subscriptions(subscriber_user_id, subscriber_project_id, scope_user_id, scope_project_id, topics, topic_groups, filter) + VALUES (#{subscriberUserId}, #{subscriberProjectId}, #{subscriptionScope}, #{subscriptionProjectId}, #{Foldable.toList subscriptionTopics}::notification_topic[], #{Foldable.toList subscriptionTopicGroups}::notification_topic_group[], #{subscriptionFilter}) RETURNING id |] -deleteNotificationSubscription :: UserId -> NotificationSubscriptionId -> Transaction e () -deleteNotificationSubscription subscriberUserId subscriptionId = do +deleteNotificationSubscription :: SubscriptionOwner -> NotificationSubscriptionId -> Transaction e () +deleteNotificationSubscription owner subscriptionId = do + let ownerFilter = case owner of + UserSubscriptionOwner subscriberUserId -> [sql| subscriber_user_id = #{subscriberUserId}|] + ProjectSubscriptionOwner projectOwnerUserId -> [sql| subscriber_project_id = #{projectOwnerUserId} |] execute_ [sql| DELETE FROM notification_subscriptions WHERE id = #{subscriptionId} - AND subscriber_user_id = #{subscriberUserId} + AND ^{ownerFilter} |] -updateNotificationSubscription :: UserId -> NotificationSubscriptionId -> Maybe (Set NotificationTopic) -> Maybe (Set NotificationTopicGroup) -> Maybe SubscriptionFilter -> Transaction e () -updateNotificationSubscription subscriberUserId subscriptionId subscriptionTopics subscriptionTopicGroups subscriptionFilter = do +updateNotificationSubscription :: SubscriptionOwner -> NotificationSubscriptionId -> Maybe (Set NotificationTopic) -> Maybe (Set NotificationTopicGroup) -> Maybe SubscriptionFilter -> Transaction e () +updateNotificationSubscription _owner _subscriptionId Nothing Nothing Nothing = pure () +updateNotificationSubscription owner subscriptionId subscriptionTopics subscriptionTopicGroups subscriptionFilter = do + let ownerFilter = case owner of + UserSubscriptionOwner subscriberUserId -> [sql| subscriber_user_id = #{subscriberUserId}|] + ProjectSubscriptionOwner projectOwnerUserId -> [sql| subscriber_project_id = #{projectOwnerUserId} |] execute_ [sql| UPDATE notification_subscriptions @@ -321,17 +277,30 @@ updateNotificationSubscription subscriberUserId subscriptionId subscriptionTopic topic_groups = COALESCE(#{Foldable.toList <$> subscriptionTopicGroups}::notification_topic_group[], topic_groups), filter = COALESCE(#{subscriptionFilter}, filter) WHERE id = #{subscriptionId} - AND subscriber_user_id = #{subscriberUserId} + AND ^{ownerFilter} |] -getNotificationSubscription :: UserId -> NotificationSubscriptionId -> Transaction e (NotificationSubscription NotificationSubscriptionId) -getNotificationSubscription subscriberUserId subscriptionId = do +expectNotificationSubscription :: SubscriptionOwner -> NotificationSubscriptionId -> Transaction e (NotificationSubscription NotificationSubscriptionId) +expectNotificationSubscription subscriptionOwner subscriptionId = do + let ownerFilter = case subscriptionOwner of + UserSubscriptionOwner subscriberUserId -> [sql| ns.subscriber_user_id = #{subscriberUserId}|] + ProjectSubscriptionOwner projectOwnerUserId -> [sql| ns.subscriber_project_id = #{projectOwnerUserId} |] queryExpect1Row [sql| - SELECT ns.id, ns.scope_user_id, ns.topics, ns.topic_groups, ns.filter + SELECT + ns.id, + ns.scope_user_id, + ns.scope_project_id, + ns.subscriber_user_id, + ns.subscriber_project_id, + ns.topics, + ns.topic_groups, + ns.filter, + ns.created_at, + ns.updated_at FROM notification_subscriptions ns WHERE ns.id = #{subscriptionId} - AND ns.subscriber_user_id = #{subscriberUserId} + AND ^{ownerFilter} |] -- | Events are complex, so for now we hydrate them one at a time using a simple traverse @@ -510,7 +479,7 @@ hydrateEventPayload = \case -- | Subscribe or unsubscribe to watching a project updateWatchProjectSubscription :: UserId -> ProjectId -> Bool -> Transaction e (Maybe NotificationSubscriptionId) updateWatchProjectSubscription userId projId shouldBeSubscribed = do - let filter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId] + let filter = SubscriptionFilter $ Aeson.object [] existing <- isUserSubscribedToWatchProject userId projId case existing of Just existingId @@ -532,12 +501,12 @@ updateWatchProjectSubscription userId projId shouldBeSubscribed = do FROM projects p WHERE p.id = #{projId} |] - Just <$> createNotificationSubscription userId projectOwnerUserId mempty (Set.singleton WatchProject) (Just filter) + Just <$> createNotificationSubscription (UserSubscriptionOwner userId) projectOwnerUserId (Just projId) mempty (Set.singleton WatchProject) (Just filter) _ -> pure Nothing isUserSubscribedToWatchProject :: UserId -> ProjectId -> Transaction e (Maybe NotificationSubscriptionId) isUserSubscribedToWatchProject userId projId = do - let filter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId] + let filter = SubscriptionFilter $ Aeson.object [] query1Col @NotificationSubscriptionId [sql| SELECT ns.id FROM notification_subscriptions ns @@ -545,6 +514,58 @@ isUserSubscribedToWatchProject userId projId = do WHERE ns.subscriber_user_id = #{userId} AND ns.scope_user_id = p.owner_user_id AND ns.topic_groups = ARRAY[#{WatchProject}::notification_topic_group] + AND ns.scope_project_id = #{projId} AND ns.filter = #{filter}::jsonb LIMIT 1 |] + +-- | We provide a wrapper layer on top of notification subscriptions and webhooks +-- to make the frontend experience a bit more intuitive. +listProjectWebhooks :: ProjectId -> PG.Transaction e [(NotificationWebhookId, Text, NotificationSubscription NotificationSubscriptionId)] +listProjectWebhooks projectId = do + PG.queryListRows @((NotificationWebhookId, Text) PG.:. NotificationSubscription NotificationSubscriptionId) + [PG.sql| + -- Only get one webhook per subscription, there _shouldn't_ be multiple webhooks per + -- subscription if everything is working correctly. + SELECT DISTINCT ON (ns.id) + nw.id, + nw.name, + -- We ignore topic groups here for now and only allow the user to configure + -- individual topic events. + ns.id, + ns.scope_user_id, + ns.scope_project_id, + ns.subscriber_user_id, + ns.subscriber_project_id, + ns.topics, + ns.topic_groups, + ns.filter, + ns.created_at, + ns.updated_at + FROM notification_subscriptions ns + JOIN notification_webhooks nw ON nw.subscription_id = ns.id + WHERE ns.subscriber_project_id = #{projectId} + |] + <&> fmap (\((webhookId, webhookName) PG.:. subscription) -> (webhookId, webhookName, subscription)) + +-- | Gets the (first) webhook associated with a project webhook notification. +expectProjectWebhook :: ProjectId -> NotificationSubscriptionId -> PG.Transaction e (NotificationWebhookId, Text) +expectProjectWebhook projectId subscriptionId = do + PG.queryExpect1Row @(NotificationWebhookId, Text) + [PG.sql| + SELECT nw.id, nw.name + FROM notification_webhooks nw + JOIN notification_subscriptions ns ON nw.subscription_id = ns.id + WHERE nw.subscription_id = #{subscriptionId} + AND ns.subscriber_project_id = #{projectId} + LIMIT 1 + |] + +webhooksForSubscription :: NotificationSubscriptionId -> Transaction e [NotificationWebhookId] +webhooksForSubscription subscriptionId = do + PG.queryListCol + [PG.sql| + SELECT nw.id + FROM notification_webhooks nw + WHERE nw.subscription_id = #{subscriptionId} + |] diff --git a/src/Share/Notifications/Types.hs b/src/Share/Notifications/Types.hs index 3b365998..564a9a1c 100644 --- a/src/Share/Notifications/Types.hs +++ b/src/Share/Notifications/Types.hs @@ -33,6 +33,7 @@ module Share.Notifications.Types CommentPayload (..), ReleasePayload (..), StatusUpdatePayload (..), + SubscriptionOwner (..), eventTopic, hydratedEventTopic, eventData_, @@ -120,24 +121,29 @@ instance Aeson.FromJSON NotificationTopic where data NotificationTopicGroup = WatchProject + | AllProjectTopics deriving (Eq, Show, Ord) instance PG.EncodeValue NotificationTopicGroup where encodeValue = HasqlEncoders.enum \case WatchProject -> "watch_project" + AllProjectTopics -> "all_project_topics" instance PG.DecodeValue NotificationTopicGroup where decodeValue = HasqlDecoders.enum \case "watch_project" -> Just WatchProject + "all_project_topics" -> Just AllProjectTopics _ -> Nothing instance Aeson.ToJSON NotificationTopicGroup where toJSON = \case WatchProject -> "watch_project" + AllProjectTopics -> "all_project_topics" instance Aeson.FromJSON NotificationTopicGroup where parseJSON = Aeson.withText "NotificationTopicGroup" \case "watch_project" -> pure WatchProject + "all_project_topics" -> pure AllProjectTopics s -> fail $ "Invalid notification topic group: " <> Text.unpack s data NotificationStatus @@ -436,6 +442,7 @@ data NotificationEvent id userInfo occurredAt eventPayload = NotificationEvent { eventId :: id, eventOccurredAt :: occurredAt, eventResourceId :: ResourceId, + eventProjectId :: Maybe ProjectId, eventData :: eventPayload, eventScope :: userInfo, eventActor :: userInfo @@ -452,14 +459,15 @@ eventUserInfo_ f NotificationEvent {eventActor, eventScope, ..} = do pure $ NotificationEvent {eventActor = eventActor', eventScope = eventScope', ..} instance (Aeson.ToJSON eventPayload, Aeson.ToJSON userInfo) => Aeson.ToJSON (NotificationEvent NotificationEventId userInfo UTCTime eventPayload) where - toJSON NotificationEvent {eventId, eventOccurredAt, eventData, eventScope, eventActor, eventResourceId} = + toJSON NotificationEvent {eventId, eventOccurredAt, eventData, eventScope, eventActor, eventResourceId, eventProjectId} = Aeson.object [ "id" Aeson..= eventId, "occurredAt" Aeson..= eventOccurredAt, "data" Aeson..= eventData, "scope" Aeson..= eventScope, "actor" Aeson..= eventActor, - "resourceId" Aeson..= eventResourceId + "resourceId" Aeson..= eventResourceId, + "projectId" Aeson..= eventProjectId ] instance (Aeson.FromJSON eventPayload, Aeson.FromJSON userInfo) => Aeson.FromJSON (NotificationEvent NotificationEventId userInfo UTCTime eventPayload) where @@ -470,7 +478,8 @@ instance (Aeson.FromJSON eventPayload, Aeson.FromJSON userInfo) => Aeson.FromJSO eventScope <- o .: "scope" eventActor <- o .: "actor" eventResourceId <- o .: "resourceId" - pure NotificationEvent {eventId, eventOccurredAt, eventData, eventScope, eventActor, eventResourceId} + eventProjectId <- o .: "projectId" + pure NotificationEvent {eventId, eventOccurredAt, eventData, eventScope, eventActor, eventResourceId, eventProjectId} instance Hasql.DecodeRow (NotificationEvent NotificationEventId UserId UTCTime NotificationEventData) where decodeRow = do @@ -479,8 +488,9 @@ instance Hasql.DecodeRow (NotificationEvent NotificationEventId UserId UTCTime N eventScope <- PG.decodeField eventActor <- PG.decodeField eventResourceId <- PG.decodeField + eventProjectId <- PG.decodeField eventData <- PG.decodeRow - pure $ NotificationEvent {eventId, eventOccurredAt, eventData, eventScope, eventActor, eventResourceId} + pure $ NotificationEvent {eventId, eventOccurredAt, eventData, eventScope, eventActor, eventResourceId, eventProjectId} type NewNotificationEvent = NotificationEvent () UserId () NotificationEventData @@ -574,39 +584,62 @@ instance Aeson.FromJSON NotificationDeliveryMethod where data NotificationSubscription id = NotificationSubscription { subscriptionId :: id, - subscriptionScope :: UserId, + subscriptionScopeUser :: UserId, + subscriptionScopeProject :: Maybe ProjectId, + subscriptionOwner :: SubscriptionOwner, subscriptionTopics :: Set NotificationTopic, subscriptionTopicGroups :: Set NotificationTopicGroup, - subscriptionFilter :: Maybe NotificationFilter + subscriptionFilter :: Maybe NotificationFilter, + subscriptionCreatedAt :: Maybe UTCTime, + subscriptionUpdatedAt :: Maybe UTCTime } + deriving (Show, Eq) instance PG.DecodeRow (NotificationSubscription NotificationSubscriptionId) where decodeRow = do subscriptionId <- PG.decodeField - subscriptionScope <- PG.decodeField + subscriptionScopeUser <- PG.decodeField + subscriptionScopeProject <- PG.decodeField + subscriberUser <- PG.decodeField + subscriberProject <- PG.decodeField + let subscriptionOwner = case (subscriberUser, subscriberProject) of + (Just uid, Nothing) -> UserSubscriptionOwner uid + (Nothing, Just pid) -> ProjectSubscriptionOwner pid + _ -> error "Invalid subscription owner in database" subscriptionTopics <- Set.fromList <$> PG.decodeField subscriptionTopicGroups <- Set.fromList <$> PG.decodeField subscriptionFilter <- PG.decodeField - pure $ NotificationSubscription {subscriptionId, subscriptionScope, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} + subscriptionCreatedAt <- PG.decodeField + subscriptionUpdatedAt <- PG.decodeField + pure $ NotificationSubscription {subscriptionId, subscriptionScopeUser, subscriptionScopeProject, subscriptionOwner, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter, subscriptionCreatedAt, subscriptionUpdatedAt} instance Aeson.ToJSON (NotificationSubscription NotificationSubscriptionId) where - toJSON NotificationSubscription {subscriptionId, subscriptionScope, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} = + toJSON NotificationSubscription {subscriptionId, subscriptionScopeUser, subscriptionScopeProject, subscriptionOwner, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter, subscriptionCreatedAt, subscriptionUpdatedAt} = Aeson.object [ "id" Aeson..= subscriptionId, - "scope" Aeson..= subscriptionScope, + "scope" Aeson..= subscriptionScopeUser, + "projectId" Aeson..= subscriptionScopeProject, + "owner" Aeson..= subscriptionOwner, "topics" Aeson..= subscriptionTopics, "topicGroups" Aeson..= subscriptionTopicGroups, - "filter" Aeson..= subscriptionFilter + "filter" Aeson..= subscriptionFilter, + "createdAt" Aeson..= subscriptionCreatedAt, + "updatedAt" Aeson..= subscriptionUpdatedAt ] instance Aeson.FromJSON (NotificationSubscription NotificationSubscriptionId) where parseJSON = Aeson.withObject "NotificationSubscription" \o -> do subscriptionId <- o .: "id" - subscriptionScope <- o .: "scope" + subscriptionScopeUser <- o .: "scope" + subscriptionScopeProject <- o .:? "scope_project" + subscriptionOwner <- o .: "owner" subscriptionTopics <- o .: "topics" subscriptionTopicGroups <- o .: "topicGroups" subscriptionFilter <- o .:? "filter" - pure NotificationSubscription {subscriptionId, subscriptionScope, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter} + subscriptionCreatedAt <- o .:? "createdAt" + subscriptionUpdatedAt <- o .:? "updatedAt" + + pure NotificationSubscription {subscriptionId, subscriptionScopeUser, subscriptionScopeProject, subscriptionOwner, subscriptionTopics, subscriptionTopicGroups, subscriptionFilter, subscriptionCreatedAt, subscriptionUpdatedAt} data NotificationHubEntry userInfo eventPayload = NotificationHubEntry { hubEntryId :: NotificationHubEntryId, @@ -638,7 +671,7 @@ instance Hasql.DecodeRow (NotificationHubEntry UserId NotificationEventData) whe hubEntryId <- PG.decodeField hubEntryStatus <- PG.decodeField hubEntryCreatedAt <- PG.decodeField - hubEntryEvent <- PG.decodeRow + hubEntryEvent <- PG.decodeRow @(NotificationEvent NotificationEventId UserId UTCTime NotificationEventData) pure $ NotificationHubEntry {hubEntryId, hubEntryEvent, hubEntryStatus, hubEntryCreatedAt} hubEntryUserInfo_ :: Traversal (NotificationHubEntry userInfo eventPayload) (NotificationHubEntry userInfo' eventPayload) userInfo userInfo' @@ -956,3 +989,28 @@ hydratedEventTopic (HydratedEvent {hydratedEventPayload}) = case hydratedEventPa HydratedProjectTicketStatusUpdatedPayload {} -> ProjectTicketStatusUpdated HydratedProjectTicketCommentPayload {} -> ProjectTicketComment HydratedProjectReleaseCreatedPayload {} -> ProjectReleaseCreated + +data SubscriptionOwner + = ProjectSubscriptionOwner ProjectId + | UserSubscriptionOwner UserId + deriving stock (Show, Eq, Ord) + +instance ToJSON SubscriptionOwner where + toJSON (ProjectSubscriptionOwner pid) = + Aeson.object + [ "kind" .= ("project" :: Text), + "id" .= pid + ] + toJSON (UserSubscriptionOwner uid) = + Aeson.object + [ "kind" .= ("user" :: Text), + "id" .= uid + ] + +instance FromJSON SubscriptionOwner where + parseJSON = Aeson.withObject "SubscriptionOwner" \o -> do + kind <- o .: "kind" + case kind of + "project" -> ProjectSubscriptionOwner <$> o .: "id" + "user" -> UserSubscriptionOwner <$> o .: "id" + _ -> fail $ "Unknown subscription owner kind: " <> Text.unpack kind diff --git a/src/Share/Postgres/Comments/Ops.hs b/src/Share/Postgres/Comments/Ops.hs index 54ccb0c9..518c24cb 100644 --- a/src/Share/Postgres/Comments/Ops.hs +++ b/src/Share/Postgres/Comments/Ops.hs @@ -40,7 +40,7 @@ createComment authorId thingId content = do { commentId, commentAuthorUserId = authorId } - (event, projectResourceId, projectOwnerUserId) <- case thingId of + (event, projectResourceId, projectOwnerUserId, projectId) <- case thingId of Left contributionId -> do Contribution {projectId, sourceBranchId, targetBranchId, author} <- ContributionQ.contributionById contributionId (projectData, projectResourceId, projectOwnerUserId) <- ProjectQ.projectNotificationData projectId @@ -51,7 +51,7 @@ createComment authorId thingId content = do toBranchId = targetBranchId, contributorUserId = author } - pure (ProjectContributionCommentData projectData contributionData commentData, projectResourceId, projectOwnerUserId) + pure (ProjectContributionCommentData projectData contributionData commentData, projectResourceId, projectOwnerUserId, projectId) Right ticketId -> do Ticket {projectId, author} <- TicketQ.ticketById ticketId (projectData, projectResourceId, projectOwnerUserId) <- ProjectQ.projectNotificationData projectId @@ -60,12 +60,13 @@ createComment authorId thingId content = do { ticketId, ticketAuthorUserId = author } - pure (ProjectTicketCommentData projectData ticketData commentData, projectResourceId, projectOwnerUserId) + pure (ProjectTicketCommentData projectData ticketData commentData, projectResourceId, projectOwnerUserId, projectId) let notifEvent = NotificationEvent { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = event, eventScope = projectOwnerUserId, eventActor = authorId diff --git a/src/Share/Postgres/Contributions/Ops.hs b/src/Share/Postgres/Contributions/Ops.hs index 046a7ef0..602a2fb3 100644 --- a/src/Share/Postgres/Contributions/Ops.hs +++ b/src/Share/Postgres/Contributions/Ops.hs @@ -78,6 +78,7 @@ createContribution authorId projectId title description status sourceBranchId ta { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = ProjectContributionCreatedData projectData contributionData, eventScope = projectOwnerUserId, eventActor = authorId @@ -151,6 +152,7 @@ insertContributionStatusChangeEvent projectId contributionId actorUserId oldStat { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = ProjectContributionStatusUpdatedData projectData contributionData statusUpdateData, eventScope = projectOwnerUserId, eventActor = actorUserId diff --git a/src/Share/Postgres/Queries.hs b/src/Share/Postgres/Queries.hs index 43fd9f89..b4d7be11 100644 --- a/src/Share/Postgres/Queries.hs +++ b/src/Share/Postgres/Queries.hs @@ -614,6 +614,7 @@ createBranch !_nlReceipt projectId branchName contributorId causalId mergeTarget { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = ProjectBranchUpdatedData projectData branchData, eventScope = projectOwnerUserId, eventActor = creatorId @@ -681,6 +682,7 @@ setBranchCausalHash !_nameLookupReceipt description callerUserId branchId causal { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = ProjectBranchUpdatedData projectData branchData, eventScope = projectOwnerUserId, eventActor = callerUserId diff --git a/src/Share/Postgres/Releases/Ops.hs b/src/Share/Postgres/Releases/Ops.hs index 346b3d88..8f01c0b3 100644 --- a/src/Share/Postgres/Releases/Ops.hs +++ b/src/Share/Postgres/Releases/Ops.hs @@ -59,6 +59,7 @@ createRelease !_nlReceipt projectId (releaseVersion@ReleaseVersion {major, minor { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = ProjectReleaseCreatedData projectData releaseData, eventScope = projectOwnerUserId, eventActor = creatorId diff --git a/src/Share/Postgres/Tickets/Ops.hs b/src/Share/Postgres/Tickets/Ops.hs index 3947eca9..d62a5314 100644 --- a/src/Share/Postgres/Tickets/Ops.hs +++ b/src/Share/Postgres/Tickets/Ops.hs @@ -60,6 +60,7 @@ createTicket authorId projectId title description status = do { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = ProjectTicketCreatedData projectData ticketData, eventScope = projectOwnerUserId, eventActor = authorId @@ -112,6 +113,7 @@ insertTicketStatusChangeEvent projectId ticketId actorUserId oldStatus newStatus { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, + eventProjectId = Just projectId, eventData = ProjectTicketStatusUpdatedData projectData ticketData statusUpdateData, eventScope = projectOwnerUserId, eventActor = actorUserId diff --git a/src/Share/Web/Authorization.hs b/src/Share/Web/Authorization.hs index 4a59379a..fd489671 100644 --- a/src/Share/Web/Authorization.hs +++ b/src/Share/Web/Authorization.hs @@ -56,6 +56,8 @@ module Share.Web.Authorization checkDeliveryMethodsManage, checkSubscriptionsView, checkSubscriptionsManage, + checkProjectSubscriptionsView, + checkProjectSubscriptionsManage, permissionGuard, readPath, writePath, @@ -165,6 +167,8 @@ data ProjectPermission | ProjectRolesEdit ProjectId | -- (RootHash, TargetHash) AccessCausalHash CausalId CausalId + | ProjectNotificationSubscriptionsView ProjectId + | ProjectNotificationSubscriptionsManage ProjectId deriving stock (Show, Eq, Ord) data UserPermission @@ -236,6 +240,8 @@ instance Errors.ToServerError AuthZFailure where ProjectRolesList _pid -> (ErrorID "authz:maintainers:list", err403 {errBody = "Permission Denied: " <> msg}) ProjectRolesEdit _pid -> (ErrorID "authz:maintainers:edit", err403 {errBody = "Permission Denied: " <> msg}) AccessCausalHash _ _ -> (ErrorID "authz:causal-hash", err403 {errBody = "Permission Denied: " <> msg}) + ProjectNotificationSubscriptionsView _pid -> (ErrorID "authz:project:notification-subscription-get", err403 {errBody = "Permission Denied: " <> msg}) + ProjectNotificationSubscriptionsManage _pid -> (ErrorID "authz:project:notification-subscription-manage", err403 {errBody = "Permission Denied: " <> msg}) UserPermission userPermission -> case userPermission of UserUpdate _uid -> (ErrorID "authz:user:update", err403 {errBody = "Permission Denied: " <> msg}) @@ -299,6 +305,8 @@ authZFailureMessage (AuthZFailure perm) = case perm of ProjectRolesList _pid -> "Not permitted to list maintainers" ProjectRolesEdit _pid -> "Not permitted to edit maintainers" AccessCausalHash _ _ -> "Not permitted to access this causal hash" + ProjectNotificationSubscriptionsView _pid -> "Not permitted to get project notifications" + ProjectNotificationSubscriptionsManage _pid -> "Not permitted to manage project notifications" UserPermission userPermission -> case userPermission of UserUpdate _uid -> "Not permitted to update this user" @@ -750,6 +758,16 @@ checkSubscriptionsManage caller notificationUser = maybePermissionFailure (UserP assertUsersEqual caller notificationUser <|> assertUserHasOrgPermissionByOrgUser caller notificationUser AuthZ.NotificationSubscriptionManage pure $ AuthZ.UnsafeAuthZReceipt Nothing +checkProjectSubscriptionsView :: UserId -> ProjectId -> WebApp (Either AuthZFailure AuthZ.AuthZReceipt) +checkProjectSubscriptionsView caller projectId = maybePermissionFailure (ProjectPermission $ ProjectNotificationSubscriptionsView projectId) do + assertUserHasProjectPermission AuthZ.ProjectManage (Just caller) projectId + pure $ AuthZ.UnsafeAuthZReceipt Nothing + +checkProjectSubscriptionsManage :: UserId -> ProjectId -> WebApp (Either AuthZFailure AuthZ.AuthZReceipt) +checkProjectSubscriptionsManage caller projectId = maybePermissionFailure (ProjectPermission $ ProjectNotificationSubscriptionsManage projectId) do + assertUserHasProjectPermission AuthZ.ProjectManage (Just caller) projectId + pure $ AuthZ.UnsafeAuthZReceipt Nothing + -- | Check whether the given user has administrative privileges, -- and has a recently created session. This adds additional protection to -- sensitive endpoints. diff --git a/src/Share/Web/Share/Projects/API.hs b/src/Share/Web/Share/Projects/API.hs index 860add47..836266a6 100644 --- a/src/Share/Web/Share/Projects/API.hs +++ b/src/Share/Web/Share/Projects/API.hs @@ -43,6 +43,7 @@ type ProjectResourceAPI = ) :<|> ("roles" :> MaintainersResourceAPI) :<|> ("subscription" :> ProjectNotificationSubscriptionEndpoint) + :<|> ("webhooks" :> ProjectWebhooksResourceAPI) ) type ProjectDiffNamespacesEndpoint = @@ -116,3 +117,26 @@ type RemoveRolesEndpoint = type ProjectNotificationSubscriptionEndpoint = ReqBody '[JSON] ProjectNotificationSubscriptionRequest :> Put '[JSON] ProjectNotificationSubscriptionResponse + +type ProjectWebhooksResourceAPI = + ( ListProjectWebhooksEndpoint + :<|> CreateProjectWebhookEndpoint + :<|> ( Capture "subscription_id" NotificationSubscriptionId + :> ( UpdateProjectWebhookEndpoint + :<|> DeleteProjectWebhookEndpoint + ) + ) + ) + +type ListProjectWebhooksEndpoint = Get '[JSON] ListProjectWebhooksResponse + +type CreateProjectWebhookEndpoint = + ReqBody '[JSON] CreateProjectWebhookRequest + :> Post '[JSON] CreateProjectWebhookResponse + +type UpdateProjectWebhookEndpoint = + ReqBody '[JSON] UpdateProjectWebhookRequest + :> Patch '[JSON] UpdateProjectWebhookResponse + +type DeleteProjectWebhookEndpoint = + Delete '[JSON] () diff --git a/src/Share/Web/Share/Projects/Impl.hs b/src/Share/Web/Share/Projects/Impl.hs index 2364e444..9da2a4ef 100644 --- a/src/Share/Web/Share/Projects/Impl.hs +++ b/src/Share/Web/Share/Projects/Impl.hs @@ -22,7 +22,9 @@ import Share.Codebase.CodebaseRuntime qualified as CR import Share.Env qualified as Env import Share.IDs (PrefixedHash (..), ProjectSlug (..), UserHandle, UserId) import Share.IDs qualified as IDs +import Share.Notifications.Ops qualified as NotifOps import Share.Notifications.Queries qualified as NotifsQ +import Share.Notifications.Types (SubscriptionOwner (ProjectSubscriptionOwner)) import Share.OAuth.Session import Share.Postgres qualified as PG import Share.Postgres.Authorization.Queries qualified as AuthZQ @@ -123,6 +125,7 @@ projectServer session handle = :<|> favProjectEndpoint session handle slug :<|> maintainersResourceServer slug :<|> projectNotificationSubscriptionEndpoint session handle slug + :<|> projectWebhooksServer session handle slug ) where addTags :: forall x. ProjectSlug -> WebApp x -> WebApp x @@ -429,3 +432,61 @@ projectNotificationSubscriptionEndpoint session projectUserHandler projectSlug ( ProjectNotificationSubscriptionResponse { subscriptionId = maySubscriptionId } + +projectWebhooksServer :: Maybe Session -> UserHandle -> ProjectSlug -> ServerT API.ProjectWebhooksResourceAPI WebApp +projectWebhooksServer session projectUserHandle projectSlug = + listProjectWebhooksEndpoint session projectUserHandle projectSlug + :<|> createProjectWebhookEndpoint session projectUserHandle projectSlug + :<|> ( \subscriptionId -> + updateProjectWebhookEndpoint session projectUserHandle projectSlug subscriptionId + :<|> deleteProjectWebhookEndpoint session projectUserHandle projectSlug subscriptionId + ) + +listProjectWebhooksEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> WebApp ListProjectWebhooksResponse +listProjectWebhooksEndpoint session projectUserHandle projectSlug = do + caller <- AuthN.requireAuthenticatedUser session + Project {projectId} <- PG.runTransactionOrRespondError $ do + Q.projectByShortHand projectShortHand `whenNothingM` throwError (EntityMissing (ErrorID "project-not-found") ("Project not found: " <> IDs.toText @IDs.ProjectShortHand projectShortHand)) + _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkProjectSubscriptionsView caller projectId + webhooks <- NotifOps.listProjectWebhooks projectId + pure $ ListProjectWebhooksResponse {webhooks} + where + projectShortHand :: IDs.ProjectShortHand + projectShortHand = IDs.ProjectShortHand {userHandle = projectUserHandle, projectSlug} + +createProjectWebhookEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> CreateProjectWebhookRequest -> WebApp CreateProjectWebhookResponse +createProjectWebhookEndpoint session projectUserHandle projectSlug (CreateProjectWebhookRequest {uri, topics}) = do + caller <- AuthN.requireAuthenticatedUser session + Project {projectId} <- PG.runTransactionOrRespondError $ do + Q.projectByShortHand projectShortHand `whenNothingM` throwError (EntityMissing (ErrorID "project-not-found") ("Project not found: " <> IDs.toText @IDs.ProjectShortHand projectShortHand)) + _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkProjectSubscriptionsManage caller projectId + webhook <- NotifOps.createProjectWebhook projectId uri topics + pure $ CreateProjectWebhookResponse {webhook} + where + projectShortHand :: IDs.ProjectShortHand + projectShortHand = IDs.ProjectShortHand {userHandle = projectUserHandle, projectSlug} + +updateProjectWebhookEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> IDs.NotificationSubscriptionId -> UpdateProjectWebhookRequest -> WebApp UpdateProjectWebhookResponse +updateProjectWebhookEndpoint session projectUserHandle projectSlug subscriptionId (UpdateProjectWebhookRequest {uri = mayURIUpdate, topics}) = do + caller <- AuthN.requireAuthenticatedUser session + Project {projectId} <- PG.runTransactionOrRespondError $ do + Q.projectByShortHand projectShortHand `whenNothingM` throwError (EntityMissing (ErrorID "project-not-found") ("Project not found: " <> IDs.toText @IDs.ProjectShortHand projectShortHand)) + _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkProjectSubscriptionsManage caller projectId + let owner = ProjectSubscriptionOwner projectId + NotifOps.updateProjectWebhook owner subscriptionId mayURIUpdate topics + webhook <- NotifOps.expectProjectWebhook projectId subscriptionId + pure $ UpdateProjectWebhookResponse {webhook} + where + projectShortHand :: IDs.ProjectShortHand + projectShortHand = IDs.ProjectShortHand {userHandle = projectUserHandle, projectSlug} + +deleteProjectWebhookEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> IDs.NotificationSubscriptionId -> WebApp () +deleteProjectWebhookEndpoint session projectUserHandle projectSlug subscriptionId = do + caller <- AuthN.requireAuthenticatedUser session + Project {projectId} <- PG.runTransactionOrRespondError $ do + Q.projectByShortHand projectShortHand `whenNothingM` throwError (EntityMissing (ErrorID "project-not-found") ("Project not found: " <> IDs.toText @IDs.ProjectShortHand projectShortHand)) + _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkProjectSubscriptionsManage caller projectId + NotifOps.deleteProjectWebhook projectId subscriptionId + where + projectShortHand :: IDs.ProjectShortHand + projectShortHand = IDs.ProjectShortHand {userHandle = projectUserHandle, projectSlug} diff --git a/src/Share/Web/Share/Projects/Types.hs b/src/Share/Web/Share/Projects/Types.hs index 8b8ce284..0d5cdabd 100644 --- a/src/Share/Web/Share/Projects/Types.hs +++ b/src/Share/Web/Share/Projects/Types.hs @@ -26,19 +26,30 @@ module Share.Web.Share.Projects.Types APIProjectBranchAndReleaseDetails (..), PermissionsInfo (..), IsSubscribed (..), + ListProjectWebhooksResponse (..), + ProjectWebhook (..), + CreateProjectWebhookRequest (..), + CreateProjectWebhookResponse (..), + UpdateProjectWebhookRequest (..), + UpdateProjectWebhookResponse (..), + ProjectWebhookTopics (..), ) where import Data.Aeson import Data.Aeson qualified as Aeson import Data.Set qualified as Set +import Data.Set.NonEmpty (NESet) +import Data.Set.NonEmpty qualified as NESet import Data.Time (UTCTime) import Share.IDs import Share.IDs qualified as IDs +import Share.Notifications.Types import Share.Postgres qualified as PG import Share.Prelude import Share.Project (Project (..), ProjectTag, ProjectVisibility (..)) import Share.Utils.API +import Share.Utils.URI (URIParam) import Share.Web.Authorization.Types (PermissionsInfo (PermissionsInfo)) projectToAPI :: ProjectOwner -> Project -> APIProject @@ -376,3 +387,146 @@ instance Aeson.ToJSON CatalogCategory where [ "name" .= name, "projects" .= projects ] + +data ProjectWebhookTopics + = SelectedTopics (NESet NotificationTopic) + | AllTopicsInProject + deriving stock (Eq, Show) + +instance ToJSON ProjectWebhookTopics where + toJSON (SelectedTopics topics) = + object + [ "type" .= ("selected" :: Text), + "topics" .= topics + ] + toJSON AllTopicsInProject = + object + [ "type" .= ("all" :: Text) + ] + +instance FromJSON ProjectWebhookTopics where + parseJSON = Aeson.withObject "ProjectWebhookTopics" $ \o -> do + typ <- o .: "type" + case typ of + ("selected" :: Text) -> do + topics <- Set.fromList <$> (o .: "topics") + case NESet.nonEmptySet topics of + Nothing -> fail "SelectedTopics must have at least one topic" + Just neset -> pure $ SelectedTopics neset + ("all" :: Text) -> pure AllTopicsInProject + _ -> fail $ "Unknown ProjectWebhookTopics type: " <> show typ + +-- | This type provides a view over the subscription <-> webhook many-to-many view. +data ProjectWebhook = ProjectWebhook + { projectWebhookUri :: URIParam, + projectWebhookTopics :: ProjectWebhookTopics, + projectWebhookNotificationSubscriptionId :: NotificationSubscriptionId, + projectWebhookCreatedAt :: Maybe UTCTime, + projectWebhookUpdatedAt :: Maybe UTCTime + } + deriving stock (Eq, Show) + +instance ToJSON ProjectWebhook where + toJSON ProjectWebhook {..} = + object + [ "uri" .= projectWebhookUri, + "topics" .= projectWebhookTopics, + "notificationSubscriptionId" .= projectWebhookNotificationSubscriptionId, + "createdAt" .= projectWebhookCreatedAt, + "updatedAt" .= projectWebhookUpdatedAt + ] + +instance FromJSON ProjectWebhook where + parseJSON = Aeson.withObject "ProjectWebhook" $ \o -> do + projectWebhookUri <- o .: "uri" + projectWebhookTopics <- o .: "topics" + projectWebhookNotificationSubscriptionId <- o .: "notificationSubscriptionId" + projectWebhookCreatedAt <- o .: "createdAt" + projectWebhookUpdatedAt <- o .: "updatedAt" + pure ProjectWebhook {..} + +data ListProjectWebhooksResponse = ListProjectWebhooksResponse + { webhooks :: [ProjectWebhook] + } + deriving stock (Eq, Show) + +instance ToJSON ListProjectWebhooksResponse where + toJSON ListProjectWebhooksResponse {..} = + object + [ "webhooks" .= webhooks + ] + +instance FromJSON ListProjectWebhooksResponse where + parseJSON = Aeson.withObject "ListProjectWebhooksResponse" $ \o -> do + webhooks <- o .: "webhooks" + pure ListProjectWebhooksResponse {..} + +data CreateProjectWebhookRequest = CreateProjectWebhookRequest + { uri :: URIParam, + topics :: ProjectWebhookTopics + } + deriving stock (Eq, Show) + +instance ToJSON CreateProjectWebhookRequest where + toJSON CreateProjectWebhookRequest {..} = + object + [ "uri" .= uri, + "topics" .= topics + ] + +instance FromJSON CreateProjectWebhookRequest where + parseJSON = Aeson.withObject "CreateProjectWebhookRequest" $ \o -> do + uri <- o .: "uri" + topics <- o .: "topics" + pure CreateProjectWebhookRequest {..} + +data CreateProjectWebhookResponse = CreateProjectWebhookResponse + { webhook :: ProjectWebhook + } + deriving stock (Eq, Show) + +instance ToJSON CreateProjectWebhookResponse where + toJSON CreateProjectWebhookResponse {..} = + object + [ "webhook" .= webhook + ] + +instance FromJSON CreateProjectWebhookResponse where + parseJSON = Aeson.withObject "CreateProjectWebhookResponse" $ \o -> do + webhook <- o .: "webhook" + pure CreateProjectWebhookResponse {..} + +data UpdateProjectWebhookRequest = UpdateProjectWebhookRequest + { uri :: Maybe URIParam, + topics :: Maybe ProjectWebhookTopics + } + deriving stock (Eq, Show) + +instance ToJSON UpdateProjectWebhookRequest where + toJSON UpdateProjectWebhookRequest {..} = + object + [ "uri" .= uri, + "topics" .= topics + ] + +instance FromJSON UpdateProjectWebhookRequest where + parseJSON = Aeson.withObject "UpdateProjectWebhookRequest" $ \o -> do + uri <- o .:? "uri" + topics <- o .:? "topics" + pure UpdateProjectWebhookRequest {..} + +data UpdateProjectWebhookResponse = UpdateProjectWebhookResponse + { webhook :: ProjectWebhook + } + deriving stock (Eq, Show) + +instance ToJSON UpdateProjectWebhookResponse where + toJSON UpdateProjectWebhookResponse {..} = + object + [ "webhook" .= webhook + ] + +instance FromJSON UpdateProjectWebhookResponse where + parseJSON = Aeson.withObject "UpdateProjectWebhookResponse" $ \o -> do + webhook <- o .: "webhook" + pure UpdateProjectWebhookResponse {..} diff --git a/transcripts/share-apis/notifications/add-project-webhook.json b/transcripts/share-apis/notifications/add-project-webhook.json new file mode 100644 index 00000000..8e10ed6c --- /dev/null +++ b/transcripts/share-apis/notifications/add-project-webhook.json @@ -0,0 +1,18 @@ +{ + "body": { + "webhook": { + "createdAt": "", + "notificationSubscriptionId": "NS-", + "topics": { + "type": "all" + }, + "updatedAt": "", + "uri": "http://:9999/good-webhook" + } + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/check-delivery-methods.json b/transcripts/share-apis/notifications/check-delivery-methods.json deleted file mode 100644 index 1fba7391..00000000 --- a/transcripts/share-apis/notifications/check-delivery-methods.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "body": { - "deliveryMethods": [ - { - "config": { - "email": "me@example.com", - "id": "NE-" - }, - "kind": "email" - }, - { - "config": { - "id": "NW-", - "url": "http://:9999/good-webhook" - }, - "kind": "webhook" - }, - { - "config": { - "id": "NW-", - "url": "http://:9999/invalid?x-set-response-status-code=500" - }, - "kind": "webhook" - } - ] - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/notifications/create-subscription-for-other-user-project.json b/transcripts/share-apis/notifications/create-subscription-for-other-user-project.json deleted file mode 100644 index baf498ad..00000000 --- a/transcripts/share-apis/notifications/create-subscription-for-other-user-project.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "body": { - "subscription": { - "filter": null, - "id": "NS-", - "scope": "U-", - "topicGroups": [ - "watch_project" - ], - "topics": [ - "project:branch:updated", - "project:release:created" - ] - } - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/notifications/add-webhook-to-subscription.json b/transcripts/share-apis/notifications/delete-project-webhook.json similarity index 100% rename from transcripts/share-apis/notifications/add-webhook-to-subscription.json rename to transcripts/share-apis/notifications/delete-project-webhook.json diff --git a/transcripts/share-apis/notifications/list-notifications-read-transcripts.json b/transcripts/share-apis/notifications/list-notifications-read-transcripts.json index ad33a97e..18a095cf 100644 --- a/transcripts/share-apis/notifications/list-notifications-read-transcripts.json +++ b/transcripts/share-apis/notifications/list-notifications-read-transcripts.json @@ -14,16 +14,37 @@ "kind": "user" }, "data": { - "kind": "project:branch:updated", - "link": "http://:1234/@test/publictestproject/code/newbranch/latest", + "kind": "project:contribution:updated", + "link": "http://:1234/@test/publictestproject/contributions/1", "payload": { - "branch": { - "branchContributorHandle": null, - "branchContributorUserId": null, - "branchId": "B-", - "branchName": "newbranch", - "branchShortHand": "newbranch", - "projectBranchShortHand": "@test/publictestproject/newbranch" + "contribution": { + "author": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "contributionId": "C-", + "description": "This contribution addresses an issue where users were unable to log in due to a validation error in the authentication process.\n\n## Changes made:\n\n* Modified the validation logic for the Auth type to properly authenticate users.\n* Added unit tests to ensure the authentication process works as expected.\n\n## Testing:\n\nI tested this change locally on my development environment and confirmed that users can now log in without any issues. All unit tests are passing.", + "number": 1, + "sourceBranch": { + "branchContributorHandle": null, + "branchContributorUserId": null, + "branchId": "B-", + "branchName": "main", + "branchShortHand": "main", + "projectBranchShortHand": "@test/publictestproject/main" + }, + "status": "closed", + "targetBranch": { + "branchContributorHandle": "transcripts", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "contribution", + "branchShortHand": "@transcripts/contribution", + "projectBranchShortHand": "@test/publictestproject/@transcripts/contribution" + }, + "title": "Fix issue with user authentication" }, "project": { "projectId": "P-", @@ -31,11 +52,16 @@ "projectOwnerUserId": "U-", "projectShortHand": "@test/publictestproject", "projectSlug": "publictestproject" + }, + "status_update": { + "newStatus": "closed", + "oldStatus": "in_review" } } }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { diff --git a/transcripts/share-apis/notifications/list-notifications-test-paging.json b/transcripts/share-apis/notifications/list-notifications-test-paging.json index ca9938cd..c3de7b33 100644 --- a/transcripts/share-apis/notifications/list-notifications-test-paging.json +++ b/transcripts/share-apis/notifications/list-notifications-test-paging.json @@ -53,6 +53,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { diff --git a/transcripts/share-apis/notifications/list-notifications-test.json b/transcripts/share-apis/notifications/list-notifications-test.json index 3f5d159a..7a004e97 100644 --- a/transcripts/share-apis/notifications/list-notifications-test.json +++ b/transcripts/share-apis/notifications/list-notifications-test.json @@ -53,6 +53,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { @@ -108,6 +109,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { @@ -189,6 +191,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { @@ -259,6 +262,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { diff --git a/transcripts/share-apis/notifications/list-notifications-transcripts.json b/transcripts/share-apis/notifications/list-notifications-transcripts.json index f94e8cf8..0dc45120 100644 --- a/transcripts/share-apis/notifications/list-notifications-transcripts.json +++ b/transcripts/share-apis/notifications/list-notifications-transcripts.json @@ -1,55 +1,6 @@ { "body": { "items": [ - { - "createdAt": "", - "event": { - "actor": { - "info": { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - "kind": "user" - }, - "data": { - "kind": "project:branch:updated", - "link": "http://:1234/@test/publictestproject/code/newbranch/latest", - "payload": { - "branch": { - "branchContributorHandle": null, - "branchContributorUserId": null, - "branchId": "B-", - "branchName": "newbranch", - "branchShortHand": "newbranch", - "projectBranchShortHand": "@test/publictestproject/newbranch" - }, - "project": { - "projectId": "P-", - "projectOwnerHandle": "test", - "projectOwnerUserId": "U-", - "projectShortHand": "@test/publictestproject", - "projectSlug": "publictestproject" - } - } - }, - "id": "EVENT-", - "occurredAt": "", - "resourceId": "RES-", - "scope": { - "info": { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - "kind": "user" - } - }, - "id": "NOT-", - "status": "unread" - }, { "createdAt": "", "event": { @@ -110,6 +61,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { @@ -156,6 +108,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { diff --git a/transcripts/share-apis/notifications/list-notifications-unread-test.json b/transcripts/share-apis/notifications/list-notifications-unread-test.json index 11149940..69d11d86 100644 --- a/transcripts/share-apis/notifications/list-notifications-unread-test.json +++ b/transcripts/share-apis/notifications/list-notifications-unread-test.json @@ -42,6 +42,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { @@ -123,6 +124,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { @@ -193,6 +195,7 @@ }, "id": "EVENT-", "occurredAt": "", + "projectId": "P-", "resourceId": "RES-", "scope": { "info": { diff --git a/transcripts/share-apis/notifications/list-subscriptions-test-after-unsubscribe.json b/transcripts/share-apis/notifications/list-project-webhooks-after-delete.json similarity index 76% rename from transcripts/share-apis/notifications/list-subscriptions-test-after-unsubscribe.json rename to transcripts/share-apis/notifications/list-project-webhooks-after-delete.json index 52126e2e..c74e5e33 100644 --- a/transcripts/share-apis/notifications/list-subscriptions-test-after-unsubscribe.json +++ b/transcripts/share-apis/notifications/list-project-webhooks-after-delete.json @@ -1,6 +1,6 @@ { "body": { - "subscriptions": [] + "webhooks": [] }, "status": [ { diff --git a/transcripts/share-apis/notifications/list-project-webhooks-after-update.json b/transcripts/share-apis/notifications/list-project-webhooks-after-update.json new file mode 100644 index 00000000..9f9c3184 --- /dev/null +++ b/transcripts/share-apis/notifications/list-project-webhooks-after-update.json @@ -0,0 +1,23 @@ +{ + "body": { + "webhooks": [ + { + "createdAt": "", + "notificationSubscriptionId": "NS-", + "topics": { + "topics": [ + "project:contribution:created" + ], + "type": "selected" + }, + "updatedAt": "", + "uri": "http://:9999/good-webhook-updated" + } + ] + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/list-project-webhooks.json b/transcripts/share-apis/notifications/list-project-webhooks.json new file mode 100644 index 00000000..c534cbcd --- /dev/null +++ b/transcripts/share-apis/notifications/list-project-webhooks.json @@ -0,0 +1,20 @@ +{ + "body": { + "webhooks": [ + { + "createdAt": "", + "notificationSubscriptionId": "NS-", + "topics": { + "type": "all" + }, + "updatedAt": "", + "uri": "http://:9999/good-webhook" + } + ] + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/list-subscriptions-test.json b/transcripts/share-apis/notifications/list-subscriptions-test.json deleted file mode 100644 index 9d0ccef0..00000000 --- a/transcripts/share-apis/notifications/list-subscriptions-test.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "body": { - "subscriptions": [ - { - "filter": { - "projectId": "P-" - }, - "id": "NS-", - "scope": "U-", - "topicGroups": [ - "watch_project" - ], - "topics": [] - } - ] - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/notifications/run.zsh b/transcripts/share-apis/notifications/run.zsh index ceae0a75..c379e0bb 100755 --- a/transcripts/share-apis/notifications/run.zsh +++ b/transcripts/share-apis/notifications/run.zsh @@ -11,37 +11,18 @@ subscription_id=$(fetch_data_jq "$test_user" PUT subscribe-to-watch-project '/us "isSubscribed": true }' ) -webhook_id=$(fetch_data_jq "$test_user" POST create-webhook '/users/test/notifications/delivery-methods/webhooks' '.webhookId' "{ - \"url\": \"${echo_server}/good-webhook\", - \"name\": \"Good Webhook\" -}" ) - -failing_webhook_id=$(fetch_data_jq "$test_user" POST create-webhook '/users/test/notifications/delivery-methods/webhooks' '.webhookId' "{ - \"url\": \"${echo_server}/invalid?x-set-response-status-code=500\", - \"name\": \"Bad Webhook\" -}" ) - -fetch "$test_user" POST add-webhook-to-subscription "/users/test/notifications/subscriptions/${subscription_id}/delivery-methods/add" "{ - \"deliveryMethods\": [{\"kind\": \"webhook\", \"id\": \"${webhook_id}\"}, {\"kind\": \"webhook\", \"id\": \"${failing_webhook_id}\"}] +fetch "$test_user" POST add-project-webhook "/users/test/projects/publictestproject/webhooks" "{ + \"uri\": \"${echo_server}/good-webhook\", + \"topics\": {\"type\": \"all\"} }" -# Add a subscription within the transcripts user to notifications for some select topics in any project owned by the test -# user. -# No filter is applied. -fetch "$transcripts_user" POST create-subscription-for-other-user-project '/users/transcripts/notifications/subscriptions' "{ - \"scope\": \"test\", - \"topics\": [ - \"project:branch:updated\", \"project:release:created\" - ], - \"topicGroups\": [\"watch_project\"] -}" +fetch "$test_user" GET list-project-webhooks "/users/test/projects/publictestproject/webhooks" -fetch "$test_user" POST create-email-delivery '/users/test/notifications/delivery-methods/emails' '{ - "email": "me@example.com" +# Subscribe the transcripts user to notifications for a project in the test user. +fetch "$transcripts_user" PUT subscribe-to-other-user-project '/users/test/projects/publictestproject/subscription' '{ + "isSubscribed": true }' -fetch "$test_user" GET check-delivery-methods '/users/test/notifications/delivery-methods' - # Create a contribution in a public project, which should trigger a notification for both users, but will be omitted # from 'transcripts' notification list since it's a self-notification. fetch "$transcripts_user" POST public-contribution-create '/users/test/projects/publictestproject/contributions' '{ @@ -149,13 +130,22 @@ unsuccessful_webhooks=$(pg_sql "SELECT COUNT(*) FROM notification_webhook_queue echo "Successful webhooks: $successful_webhooks\nUnsuccessful webhooks: $unsuccessful_webhooks\n" > ./webhook_results.txt -# List 'test' user's subscriptions -fetch "$test_user" GET list-subscriptions-test '/users/test/notifications/subscriptions' - # Can unsubscribe from project-related notifications for the test user's publictestproject. fetch "$test_user" PUT unsubscribe-from-project '/users/test/projects/publictestproject/subscription' '{ "isSubscribed": false }' -# List 'test' user's subscriptions again -fetch "$test_user" GET list-subscriptions-test-after-unsubscribe '/users/test/notifications/subscriptions' +# Get the project webhook id. +project_webhook_id=$(fetch_data_jq "$test_user" GET project-webhooks-fetch '/users/test/projects/publictestproject/webhooks' '.webhooks[0].notificationSubscriptionId') + +# Can update project webhooks +fetch "$test_user" PATCH update-project-webhook "/users/test/projects/publictestproject/webhooks/$project_webhook_id" "{ + \"uri\": \"${echo_server}/good-webhook-updated\", + \"topics\": {\"type\": \"selected\", \"topics\": [\"project:contribution:created\"]} +}" + +fetch "$test_user" GET list-project-webhooks-after-update "/users/test/projects/publictestproject/webhooks" + +fetch "$test_user" DELETE delete-project-webhook "/users/test/projects/publictestproject/webhooks/$project_webhook_id" + +fetch "$test_user" GET list-project-webhooks-after-delete "/users/test/projects/publictestproject/webhooks" diff --git a/transcripts/share-apis/notifications/create-email-delivery.json b/transcripts/share-apis/notifications/subscribe-to-other-user-project.json similarity index 64% rename from transcripts/share-apis/notifications/create-email-delivery.json rename to transcripts/share-apis/notifications/subscribe-to-other-user-project.json index ee91e366..c2b44bbf 100644 --- a/transcripts/share-apis/notifications/create-email-delivery.json +++ b/transcripts/share-apis/notifications/subscribe-to-other-user-project.json @@ -1,6 +1,6 @@ { "body": { - "emailDeliveryMethodId": "NE-" + "subscriptionId": "NS-" }, "status": [ { diff --git a/transcripts/share-apis/notifications/update-project-webhook.json b/transcripts/share-apis/notifications/update-project-webhook.json new file mode 100644 index 00000000..237c74ac --- /dev/null +++ b/transcripts/share-apis/notifications/update-project-webhook.json @@ -0,0 +1,21 @@ +{ + "body": { + "webhook": { + "createdAt": "", + "notificationSubscriptionId": "NS-", + "topics": { + "topics": [ + "project:contribution:created" + ], + "type": "selected" + }, + "updatedAt": "", + "uri": "http://:9999/good-webhook-updated" + } + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/webhook_results.txt b/transcripts/share-apis/notifications/webhook_results.txt index 7b98feeb..f1be98ec 100644 --- a/transcripts/share-apis/notifications/webhook_results.txt +++ b/transcripts/share-apis/notifications/webhook_results.txt @@ -1,3 +1,3 @@ -Successful webhooks: 6 -Unsuccessful webhooks: 6 +Successful webhooks: 7 +Unsuccessful webhooks: 0