diff --git a/share-api.cabal b/share-api.cabal index 25928f85..7e2296de 100644 --- a/share-api.cabal +++ b/share-api.cabal @@ -64,6 +64,7 @@ library Share.Postgres.Causal.Conversions Share.Postgres.Causal.Queries Share.Postgres.Causal.Types + Share.Postgres.Comments.Ops Share.Postgres.Comments.Queries Share.Postgres.Contributions.Ops Share.Postgres.Contributions.Queries @@ -93,6 +94,7 @@ library Share.Postgres.Sync.Conversions Share.Postgres.Sync.Queries Share.Postgres.Sync.Types + Share.Postgres.Tickets.Ops Share.Postgres.Tickets.Queries Share.Postgres.Users.Queries Share.Prelude diff --git a/sql/2025-06-02_comment-content.sql b/sql/2025-06-02_comment-content.sql new file mode 100644 index 00000000..29fea5b3 --- /dev/null +++ b/sql/2025-06-02_comment-content.sql @@ -0,0 +1,14 @@ +-- Create a View with the latest content for each comment. +CREATE VIEW comment_content(comment_id, created_at, updated_at, content, author_id, contribution_id, ticket_id) AS + SELECT DISTINCT ON (c.id) + c.id AS comment_id, + c.created_at, + c.updated_at, + cr.content, + c.author_id, + c.contribution_id, + c.ticket_id + FROM comments c + JOIN comment_revisions cr ON c.id = cr.comment_id + ORDER BY c.id, cr.revision_number DESC +; diff --git a/sql/2025-06-03_more-notification-topics.sql b/sql/2025-06-03_more-notification-topics.sql new file mode 100644 index 00000000..d38bbb85 --- /dev/null +++ b/sql/2025-06-03_more-notification-topics.sql @@ -0,0 +1,44 @@ +ALTER TYPE notification_topic ADD VALUE 'project:contribution:updated'; +ALTER TYPE notification_topic ADD VALUE 'project:ticket:created'; +ALTER TYPE notification_topic ADD VALUE 'project:ticket:updated'; +ALTER TYPE notification_topic ADD VALUE 'project:contribution:comment'; +ALTER TYPE notification_topic ADD VALUE 'project:ticket:comment'; + +INSERT INTO notification_topic_group_topics (topic_group, topic) +VALUES + ('watch_project', 'project:contribution:updated'), + ('watch_project', 'project:ticket:created'), + ('watch_project', 'project:ticket:updated'), + ('watch_project', 'project:contribution:comment'), + ('watch_project', 'project:ticket:comment') +; + + +-- Returns the permission a user must have for an event's resource in order to be notified. +CREATE OR REPLACE FUNCTION topic_permission(topic notification_topic) +RETURNS permission +PARALLEL SAFE +IMMUTABLE +AS $$ +BEGIN + CASE topic + WHEN 'project:branch:updated' THEN + RETURN 'project:view'::permission; + WHEN 'project:contribution:created' THEN + RETURN 'project:view'::permission; + WHEN 'project:contribution:updated' THEN + RETURN 'project:view'::permission; + WHEN 'project:contribution:comment' THEN + RETURN 'project:view'::permission; + WHEN 'project:ticket:created' THEN + RETURN 'project:view'::permission; + WHEN 'project:ticket:updated' THEN + RETURN 'project:view'::permission; + WHEN 'project:ticket:comment' THEN + RETURN 'project:view'::permission; + ELSE + RAISE EXCEPTION 'topic_permissions: topic % must declare its necessary permissions', topic; + END CASE; +END; +$$ LANGUAGE plpgsql; + diff --git a/src/Share/BackgroundJobs/Webhooks/Worker.hs b/src/Share/BackgroundJobs/Webhooks/Worker.hs index 5d3f6ff7..bbe5d797 100644 --- a/src/Share/BackgroundJobs/Webhooks/Worker.hs +++ b/src/Share/BackgroundJobs/Webhooks/Worker.hs @@ -240,54 +240,97 @@ buildWebhookRequest webhookId uri event defaultPayload = do HTTPClient.requestBody = HTTPClient.RequestBodyLBS $ Aeson.encode defaultPayload } + contributionChatMessage :: NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEvent -> Author -> Maybe URI -> ProjectContributionPayload -> (ProjectBranchShortHand -> Text) -> Background (ChatApps.MessageContent provider) + contributionChatMessage event author mainLink payload mkPretext = do + let pbShorthand = (projectBranchShortHandFromParts payload.projectInfo.projectShortHand payload.contributionInfo.contributionSourceBranch.branchShortHand) + title = payload.contributionInfo.contributionTitle + description = fromMaybe "" $ payload.contributionInfo.contributionDescription + pure $ + ChatApps.MessageContent + { preText = mkPretext pbShorthand, + content = description, + title = title, + mainLink, + author, + thumbnailUrl = Nothing, + timestamp = event.eventOccurredAt + } + ticketChatMessage :: NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEvent -> Author -> Maybe URI -> ProjectTicketPayload -> (ProjectShortHand -> Text) -> Background (ChatApps.MessageContent provider) + ticketChatMessage event author mainLink payload mkPretext = do + let title = payload.ticketInfo.ticketTitle + description = fromMaybe "" $ payload.ticketInfo.ticketDescription + pure $ + ChatApps.MessageContent + { preText = mkPretext payload.projectInfo.projectShortHand, + content = description, + title = title, + mainLink, + author, + thumbnailUrl = Nothing, + timestamp = event.eventOccurredAt + } + branchMessage :: NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEvent -> Author -> (Maybe URI) -> ProjectBranchUpdatedPayload -> Background (ChatApps.MessageContent provider) + branchMessage event author mainLink payload = do + let pbShorthand = (projectBranchShortHandFromParts payload.projectInfo.projectShortHand payload.branchInfo.branchShortHand) + title = "Branch " <> IDs.toText pbShorthand <> " was just updated." + preText = title + pure $ + ChatApps.MessageContent + { preText = preText, + content = "Branch updated", + title = title, + mainLink, + author, + thumbnailUrl = Nothing, + timestamp = event.eventOccurredAt + } + mkAuthor :: NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEvent -> Background Author + mkAuthor event = do + actorLink <- Links.userProfilePage (event.eventActor ^. DisplayInfo.handle_) + pure $ + Author + { authorName = Just actorAuthor, + authorLink = Just actorLink, + authorAvatarUrl = actorAvatarUrl + } + where + actorName = event.eventActor ^. DisplayInfo.name_ + actorHandle = "(" <> IDs.toText (PrefixedID @"@" $ event.eventActor ^. DisplayInfo.handle_) <> ")" + actorAuthor = maybe "" (<> " ") actorName <> actorHandle + actorAvatarUrl = event.eventActor ^. DisplayInfo.avatarUrl_ buildChatAppPayload :: forall provider. (ToJSON (ChatApps.MessageContent provider)) => Proxy provider -> URI -> Background (Either WebhookSendFailure HTTPClient.Request) buildChatAppPayload _ uri = do - let actorName = event.eventActor ^. DisplayInfo.name_ - actorHandle = "(" <> IDs.toText (PrefixedID @"@" $ event.eventActor ^. DisplayInfo.handle_) <> ")" - actorAuthor = maybe "" (<> " ") actorName <> actorHandle - actorAvatarUrl = event.eventActor ^. DisplayInfo.avatarUrl_ - actorLink <- Links.userProfilePage (event.eventActor ^. DisplayInfo.handle_) let mainLink = Just event.eventData.hydratedEventLink + author <- mkAuthor event messageContent :: ChatApps.MessageContent provider <- case event.eventData.hydratedEventPayload of HydratedProjectBranchUpdatedPayload payload -> do - let pbShorthand = (projectBranchShortHandFromParts payload.projectInfo.projectShortHand payload.branchInfo.branchShortHand) - title = "Branch " <> IDs.toText pbShorthand <> " was just updated." - preText = title - pure $ - ChatApps.MessageContent - { preText = preText, - content = "Branch updated", - title = title, - mainLink, - author = - Author - { authorName = Just actorAuthor, - authorLink = Just actorLink, - authorAvatarUrl = actorAvatarUrl - }, - thumbnailUrl = Nothing, - timestamp = event.eventOccurredAt - } + branchMessage event author mainLink payload HydratedProjectContributionCreatedPayload payload -> do - let pbShorthand = (projectBranchShortHandFromParts payload.projectInfo.projectShortHand payload.contributionInfo.contributionSourceBranch.branchShortHand) - title = payload.contributionInfo.contributionTitle - description = fromMaybe "" $ payload.contributionInfo.contributionDescription - preText = "New Contribution in " <> IDs.toText pbShorthand - pure $ - ChatApps.MessageContent - { preText = preText, - content = description, - title = title, - mainLink, - author = - Author - { authorName = Just actorAuthor, - authorLink = Just actorLink, - authorAvatarUrl = actorAvatarUrl - }, - thumbnailUrl = Nothing, - timestamp = event.eventOccurredAt - } + let mkPretext pbShorthand = "New Contribution in " <> IDs.toText pbShorthand + contributionChatMessage event author mainLink payload mkPretext + HydratedProjectContributionUpdatedPayload payload -> do + let mkPretext pbShorthand = "Updated Contribution in " <> IDs.toText pbShorthand + contributionChatMessage event author mainLink payload mkPretext + HydratedProjectContributionCommentPayload payload comment -> do + let mkPretext pbShorthand = "New Comment on Contribution in " <> IDs.toText pbShorthand + contributionChatMessage event author mainLink payload mkPretext + <&> \msgContent -> + msgContent + { ChatApps.content = comment.commentContent + } + HydratedProjectTicketCreatedPayload payload -> do + let mkPretext projectShorthand = "New Ticket in " <> IDs.toText projectShorthand + ticketChatMessage event author mainLink payload mkPretext + HydratedProjectTicketUpdatedPayload payload -> do + let mkPretext projectShorthand = "Updated Ticket in " <> IDs.toText projectShorthand + ticketChatMessage event author mainLink payload mkPretext + HydratedProjectTicketCommentPayload payload comment -> do + let mkPretext projectShorthand = "New Comment on Ticket in " <> IDs.toText projectShorthand + ticketChatMessage event author mainLink payload mkPretext + <&> \msgContent -> + msgContent + { ChatApps.content = comment.commentContent + } pure $ HTTPClient.requestFromURI uri & mapLeft (\e -> InvalidRequest event.eventId webhookId e) diff --git a/src/Share/Notifications/Queries.hs b/src/Share/Notifications/Queries.hs index 6127bc46..71fac334 100644 --- a/src/Share/Notifications/Queries.hs +++ b/src/Share/Notifications/Queries.hs @@ -33,8 +33,10 @@ import Share.Notifications.Types import Share.Postgres import Share.Postgres qualified as PG import Share.Postgres.Contributions.Queries qualified as ContributionQ +import Share.Postgres.Tickets.Queries qualified as TicketQ import Share.Postgres.Users.Queries qualified as UsersQ import Share.Prelude +import Share.Ticket import Share.Utils.API (Cursor (..), CursorDirection (..)) import Share.Web.Share.DisplayInfo.Queries qualified as DisplayInfoQ import Share.Web.Share.DisplayInfo.Types (UnifiedDisplayInfo) @@ -315,17 +317,64 @@ getNotificationSubscription subscriberUserId subscriptionId = do hydrateEventPayload :: forall m. (QueryA m) => NotificationEventData -> m HydratedEventPayload hydrateEventPayload = \case ProjectBranchUpdatedData - (ProjectBranchData {projectId, branchId}) -> do + (ProjectData {projectId}) + (BranchData {branchId}) -> do HydratedProjectBranchUpdatedPayload <$> hydrateProjectBranchPayload projectId branchId ProjectContributionCreatedData - (ProjectContributionData {projectId, contributionId, fromBranchId, toBranchId, contributorUserId}) -> do - HydratedProjectContributionCreatedPayload <$> hydrateContributionCreatedPayload contributionId projectId fromBranchId toBranchId contributorUserId + (ProjectData {projectId}) + (ContributionData {contributionId, fromBranchId, toBranchId, contributorUserId}) -> do + HydratedProjectContributionCreatedPayload <$> hydrateContributionPayload contributionId projectId fromBranchId toBranchId contributorUserId + ProjectContributionUpdatedData + (ProjectData {projectId}) + (ContributionData {contributionId, fromBranchId, toBranchId, contributorUserId}) -> do + HydratedProjectContributionUpdatedPayload <$> hydrateContributionPayload contributionId projectId fromBranchId toBranchId contributorUserId + ProjectContributionCommentData + (ProjectData {projectId}) + (ContributionData {contributionId, fromBranchId, toBranchId, contributorUserId}) + (CommentData {commentId, commentAuthorUserId}) -> do + HydratedProjectContributionCommentPayload + <$> hydrateContributionPayload contributionId projectId fromBranchId toBranchId contributorUserId + <*> hydrateCommentPayload commentId commentAuthorUserId + ProjectTicketCreatedData + (ProjectData {projectId}) + (TicketData {ticketId, ticketAuthorUserId}) -> do + HydratedProjectTicketCreatedPayload <$> hydrateTicketPayload projectId ticketId ticketAuthorUserId + ProjectTicketUpdatedData + (ProjectData {projectId}) + (TicketData {ticketId, ticketAuthorUserId}) -> do + HydratedProjectTicketUpdatedPayload <$> hydrateTicketPayload projectId ticketId ticketAuthorUserId + ProjectTicketCommentData + (ProjectData {projectId}) + (TicketData {ticketId, ticketAuthorUserId}) + (CommentData {commentId, commentAuthorUserId}) -> do + HydratedProjectTicketCommentPayload + <$> hydrateTicketPayload projectId ticketId ticketAuthorUserId + <*> hydrateCommentPayload commentId commentAuthorUserId where - hydrateContributionCreatedPayload :: ContributionId -> ProjectId -> BranchId -> BranchId -> UserId -> m ProjectContributionCreatedPayload - hydrateContributionCreatedPayload contributionId projectId fromBranchId toBranchId authorUserId = do + hydrateTicketPayload :: ProjectId -> TicketId -> UserId -> m ProjectTicketPayload + hydrateTicketPayload projectId ticketId authorUserId = do + projectInfo <- hydrateProjectPayload projectId + ticketInfo <- hydrateTicketInfo ticketId authorUserId + pure $ ProjectTicketPayload {projectInfo, ticketInfo} + hydrateTicketInfo :: TicketId -> UserId -> m TicketPayload + hydrateTicketInfo ticketId authorUserId = do + author <- UsersQ.userDisplayInfoOf id authorUserId + ticket <- TicketQ.ticketById ticketId + pure $ + TicketPayload + { ticketId, + ticketNumber = ticket.number, + ticketTitle = ticket.title, + ticketDescription = ticket.description, + ticketStatus = ticket.status, + ticketAuthor = author, + ticketCreatedAt = ticket.createdAt + } + hydrateContributionPayload :: ContributionId -> ProjectId -> BranchId -> BranchId -> UserId -> m ProjectContributionPayload + hydrateContributionPayload contributionId projectId fromBranchId toBranchId authorUserId = do projectInfo <- hydrateProjectPayload projectId contributionInfo <- hydrateContributionInfo contributionId fromBranchId toBranchId authorUserId - pure $ ProjectContributionCreatedPayload {projectInfo, contributionInfo = contributionInfo projectInfo} + pure $ ProjectContributionPayload {projectInfo, contributionInfo = contributionInfo projectInfo} hydrateContributionInfo :: ContributionId -> BranchId -> BranchId -> UserId -> m (ProjectPayload -> ContributionPayload) hydrateContributionInfo contributionId fromBranchId toBranchId authorUserId = do author <- UsersQ.userDisplayInfoOf id authorUserId @@ -341,7 +390,8 @@ hydrateEventPayload = \case contributionStatus = contribution.status, contributionAuthor = author, contributionSourceBranch = sourceBranch projectInfo, - contributionTargetBranch = targetBranch projectInfo + contributionTargetBranch = targetBranch projectInfo, + contributionCreatedAt = contribution.createdAt } hydrateProjectBranchPayload projectId branchId = do projectInfo <- hydrateProjectPayload projectId @@ -395,3 +445,22 @@ hydrateEventPayload = \case projectOwnerHandle, projectOwnerUserId } + hydrateCommentPayload :: CommentId -> UserId -> m CommentPayload + hydrateCommentPayload commentId commentAuthorUserId = do + let construct (commentId, commentContent, commentCreatedAt) commentAuthor = + CommentPayload + { commentId, + commentContent, + commentCreatedAt, + commentAuthor + } + construct + <$> ( queryExpect1Row + [sql| + SELECT cc.comment_id, cc.content, cc.created_at, cc.updated_at + FROM comment_content cc + JOIN users author ON cc.author_id = author.id + WHERE cc.comment_id = #{commentId} + |] + ) + <*> (UsersQ.userDisplayInfoOf id commentAuthorUserId) diff --git a/src/Share/Notifications/Types.hs b/src/Share/Notifications/Types.hs index e6285222..27b95369 100644 --- a/src/Share/Notifications/Types.hs +++ b/src/Share/Notifications/Types.hs @@ -10,8 +10,11 @@ module Share.Notifications.Types PGNotificationEvent, NotificationSubscription (..), SubscriptionFilter (..), - ProjectBranchData (..), - ProjectContributionData (..), + ProjectData (..), + BranchData (..), + ContributionData (..), + TicketData (..), + CommentData (..), NotificationHubEntry (..), NotificationStatus (..), DeliveryMethodId (..), @@ -23,8 +26,11 @@ module Share.Notifications.Types BranchPayload (..), ProjectPayload (..), ContributionPayload (..), + TicketPayload (..), + ProjectTicketPayload (..), + CommentPayload (..), ProjectBranchUpdatedPayload (..), - ProjectContributionCreatedPayload (..), + ProjectContributionPayload (..), eventTopic, hydratedEventTopic, eventData_, @@ -48,34 +54,61 @@ import Share.Contribution (ContributionStatus) import Share.IDs import Share.Postgres qualified as PG import Share.Prelude +import Share.Ticket (TicketStatus) +import Share.Utils.API import Share.Utils.URI (URIParam (..)) import Share.Web.Share.DisplayInfo.Types (UserDisplayInfo) data NotificationTopic = ProjectBranchUpdated | ProjectContributionCreated + | ProjectContributionUpdated + | ProjectContributionComment + | ProjectTicketCreated + | ProjectTicketUpdated + | ProjectTicketComment deriving (Eq, Show, Ord) instance PG.EncodeValue NotificationTopic where encodeValue = HasqlEncoders.enum \case ProjectBranchUpdated -> "project:branch:updated" ProjectContributionCreated -> "project:contribution:created" + ProjectContributionUpdated -> "project:contribution:updated" + ProjectContributionComment -> "project:contribution:comment" + ProjectTicketCreated -> "project:ticket:created" + ProjectTicketUpdated -> "project:ticket:updated" + ProjectTicketComment -> "project:ticket:comment" instance PG.DecodeValue NotificationTopic where decodeValue = HasqlDecoders.enum \case "project:branch:updated" -> Just ProjectBranchUpdated "project:contribution:created" -> Just ProjectContributionCreated + "project:contribution:updated" -> Just ProjectContributionUpdated + "project:contribution:comment" -> Just ProjectContributionComment + "project:ticket:created" -> Just ProjectTicketCreated + "project:ticket:updated" -> Just ProjectTicketUpdated + "project:ticket:comment" -> Just ProjectTicketComment _ -> Nothing instance Aeson.ToJSON NotificationTopic where toJSON = \case ProjectBranchUpdated -> "project:branch:updated" ProjectContributionCreated -> "project:contribution:created" + ProjectContributionUpdated -> "project:contribution:updated" + ProjectContributionComment -> "project:contribution:comment" + ProjectTicketCreated -> "project:ticket:created" + ProjectTicketUpdated -> "project:ticket:updated" + ProjectTicketComment -> "project:ticket:comment" instance Aeson.FromJSON NotificationTopic where parseJSON = Aeson.withText "NotificationTopic" \case "project:branch:updated" -> pure ProjectBranchUpdated "project:contribution:created" -> pure ProjectContributionCreated + "project:contribution:updated" -> pure ProjectContributionUpdated + "project:contribution:comment" -> pure ProjectContributionComment + "project:ticket:created" -> pure ProjectTicketCreated + "project:ticket:updated" -> pure ProjectTicketUpdated + "project:ticket:comment" -> pure ProjectTicketComment s -> fail $ "Invalid notification topic: " <> Text.unpack s data NotificationTopicGroup @@ -156,67 +189,117 @@ instance PG.EncodeValue NotificationFilter where HasqlEncoders.jsonb & contramap \(NotificationFilter obj) -> Aeson.toJSON obj -data ProjectBranchData = ProjectBranchData +data BranchData = BranchData + { branchId :: BranchId, + branchContributorUserId :: Maybe UserId + } + deriving stock (Eq, Show) + +instance Aeson.ToJSON BranchData where + toJSON BranchData {branchId, branchContributorUserId} = + Aeson.object + [ "branchId" .= branchId, + "branchContributorUserId" .= branchContributorUserId + ] + +instance Aeson.FromJSON BranchData where + parseJSON = Aeson.withObject "ProjectBranchData" \o -> do + branchId <- o .: "branchId" + branchContributorUserId <- o .: "branchContributorUserId" + pure BranchData {branchId, branchContributorUserId} + +data CommentData = CommentData + { commentId :: CommentId, + commentAuthorUserId :: UserId + } + deriving stock (Eq, Show) + +instance Aeson.ToJSON CommentData where + toJSON CommentData {commentAuthorUserId, commentId} = + Aeson.object + [ "commentId" .= commentId, + "commentAuthorUserId" .= commentAuthorUserId + ] + +instance Aeson.FromJSON CommentData where + parseJSON = Aeson.withObject "CommentData" \o -> do + commentId <- o .: "commentId" + commentAuthorUserId <- o .: "commentAuthorUserId" + pure CommentData {commentId, commentAuthorUserId} + +data TicketData = TicketData + { ticketId :: TicketId, + ticketAuthorUserId :: UserId + } + deriving stock (Eq, Show) + +instance Aeson.ToJSON TicketData where + toJSON TicketData {ticketId, ticketAuthorUserId} = + Aeson.object + [ "ticketId" .= ticketId, + "ticketAuthorUserId" .= ticketAuthorUserId + ] + +instance Aeson.FromJSON TicketData where + parseJSON = Aeson.withObject "ProjectTicketData" \o -> do + ticketId <- o .: "ticketId" + ticketAuthorUserId <- o .: "ticketAuthorUserId" + pure TicketData {ticketId, ticketAuthorUserId} + +data ProjectData = ProjectData { projectId :: ProjectId, - branchId :: BranchId, - branchContributorUserId :: Maybe UserId, public :: Bool } deriving stock (Eq, Show) -instance Aeson.ToJSON ProjectBranchData where - toJSON ProjectBranchData {projectId, branchId, branchContributorUserId, public} = +instance Aeson.ToJSON ProjectData where + toJSON ProjectData {projectId, public} = Aeson.object [ "projectId" .= projectId, - "branchId" .= branchId, - "branchContributorUserId" .= branchContributorUserId, "public" .= public ] -instance Aeson.FromJSON ProjectBranchData where - parseJSON = Aeson.withObject "ProjectBranchData" \o -> do +instance Aeson.FromJSON ProjectData where + parseJSON = Aeson.withObject "ProjectData" \o -> do projectId <- o .: "projectId" - branchId <- o .: "branchId" - branchContributorUserId <- o .: "branchContributorUserId" public <- o .: "public" - pure ProjectBranchData {projectId, branchId, branchContributorUserId, public} + pure ProjectData {projectId, public} -data ProjectContributionData = ProjectContributionData - { projectId :: ProjectId, - contributionId :: ContributionId, +data ContributionData = ContributionData + { contributionId :: ContributionId, fromBranchId :: BranchId, toBranchId :: BranchId, - contributorUserId :: UserId, - public :: Bool + contributorUserId :: UserId } deriving stock (Eq, Show) -instance Aeson.ToJSON ProjectContributionData where - toJSON ProjectContributionData {projectId, contributionId, fromBranchId, toBranchId, contributorUserId, public} = +instance Aeson.ToJSON ContributionData where + toJSON ContributionData {contributionId, fromBranchId, toBranchId, contributorUserId} = Aeson.object - [ "projectId" .= projectId, - "contributionId" .= contributionId, + [ "contributionId" .= contributionId, "fromBranchId" .= fromBranchId, "toBranchId" .= toBranchId, - "contributorUserId" .= contributorUserId, - "public" .= public + "contributorUserId" .= contributorUserId ] -instance Aeson.FromJSON ProjectContributionData where +instance Aeson.FromJSON ContributionData where parseJSON = Aeson.withObject "ProjectContributionData" \o -> do - projectId <- o .: "projectId" contributionId <- o .: "contributionId" fromBranchId <- o .: "fromBranchId" toBranchId <- o .: "toBranchId" contributorUserId <- o .: "contributorUserId" - public <- o .: "public" - pure ProjectContributionData {projectId, contributionId, fromBranchId, toBranchId, contributorUserId, public} + pure ContributionData {contributionId, fromBranchId, toBranchId, contributorUserId} -- The bare-bones Notification Event Data that's actually stored in the database. -- It holds unhydrated IDs. data NotificationEventData - = ProjectBranchUpdatedData ProjectBranchData - | ProjectContributionCreatedData ProjectContributionData + = ProjectBranchUpdatedData ProjectData BranchData + | ProjectContributionCreatedData ProjectData ContributionData + | ProjectContributionUpdatedData ProjectData ContributionData + | ProjectContributionCommentData ProjectData ContributionData CommentData + | ProjectTicketCreatedData ProjectData TicketData + | ProjectTicketUpdatedData ProjectData TicketData + | ProjectTicketCommentData ProjectData TicketData CommentData deriving stock (Eq, Show) instance Aeson.ToJSON NotificationEventData where @@ -228,23 +311,52 @@ instance Aeson.ToJSON NotificationEventData where where topic = eventTopic ned body = case ned of - ProjectBranchUpdatedData d -> Aeson.toJSON d - ProjectContributionCreatedData d -> Aeson.toJSON d + ProjectBranchUpdatedData project branch -> Aeson.toJSON (project :++ branch) + ProjectContributionCreatedData project c -> Aeson.toJSON (project :++ c) + ProjectContributionUpdatedData project contr -> Aeson.toJSON (project :++ contr) + ProjectContributionCommentData project contr comm -> Aeson.toJSON (project :++ contr :++ comm) + ProjectTicketCreatedData project ticket -> Aeson.toJSON (project :++ ticket) + ProjectTicketUpdatedData project ticket -> Aeson.toJSON (project :++ ticket) + ProjectTicketCommentData project ticket comm -> Aeson.toJSON (project :++ ticket :++ comm) instance PG.EncodeValue NotificationEventData where encodeValue = HasqlEncoders.jsonb & contramap \case - ProjectBranchUpdatedData d -> Aeson.toJSON d - ProjectContributionCreatedData d -> Aeson.toJSON d + ProjectBranchUpdatedData project branch -> Aeson.toJSON (project :++ branch) + ProjectContributionCreatedData project contr -> Aeson.toJSON (project :++ contr) + ProjectContributionUpdatedData project contr -> Aeson.toJSON (project :++ contr) + ProjectContributionCommentData project contr comm -> Aeson.toJSON (project :++ contr :++ comm) + ProjectTicketCreatedData project ticket -> Aeson.toJSON (project :++ ticket) + ProjectTicketUpdatedData project ticket -> Aeson.toJSON (project :++ ticket) + ProjectTicketCommentData project ticket comm -> Aeson.toJSON (project :++ ticket :++ comm) instance Hasql.DecodeRow NotificationEventData where decodeRow = do topic <- PG.decodeField Hasql.Jsonb jsonData <- PG.decodeField case topic of - ProjectBranchUpdated -> ProjectBranchUpdatedData <$> parseJsonData jsonData - ProjectContributionCreated -> ProjectContributionCreatedData <$> parseJsonData jsonData + ProjectBranchUpdated -> do + (project :++ branch) <- parseJsonData jsonData + pure $ ProjectBranchUpdatedData project branch + ProjectContributionCreated -> do + (project :++ contr) <- parseJsonData jsonData + pure $ ProjectContributionCreatedData project contr + ProjectContributionUpdated -> do + (project :++ contr) <- parseJsonData jsonData + pure $ ProjectContributionUpdatedData project contr + ProjectContributionComment -> do + (project :++ contr :++ comm) <- parseJsonData jsonData + pure $ ProjectContributionCommentData project contr comm + ProjectTicketCreated -> do + (project :++ ticket) <- parseJsonData jsonData + pure $ ProjectTicketCreatedData project ticket + ProjectTicketUpdated -> do + (project :++ ticket) <- parseJsonData jsonData + pure $ ProjectTicketUpdatedData project ticket + ProjectTicketComment -> do + (project :++ ticket :++ comm) <- parseJsonData jsonData + pure $ ProjectTicketCommentData project ticket comm where parseJsonData v = case Aeson.fromJSON v of Aeson.Error e -> fail e @@ -254,6 +366,11 @@ eventTopic :: NotificationEventData -> NotificationTopic eventTopic = \case ProjectBranchUpdatedData {} -> ProjectBranchUpdated ProjectContributionCreatedData {} -> ProjectContributionCreated + ProjectContributionUpdatedData {} -> ProjectContributionUpdated + ProjectContributionCommentData {} -> ProjectContributionComment + ProjectTicketCreatedData {} -> ProjectTicketCreated + ProjectTicketUpdatedData {} -> ProjectTicketUpdated + ProjectTicketCommentData {} -> ProjectTicketComment -- | Description of a notifiable event. data NotificationEvent id userInfo occurredAt eventPayload = NotificationEvent @@ -501,6 +618,84 @@ instance FromJSON ProjectBranchUpdatedPayload where branchInfo <- o .: "branch" pure ProjectBranchUpdatedPayload {projectInfo, branchInfo} +data CommentPayload = CommentPayload + { commentId :: CommentId, + commentContent :: Text, + commentCreatedAt :: UTCTime, + commentAuthor :: UserDisplayInfo + } + deriving stock (Show, Eq) + +instance ToJSON CommentPayload where + toJSON CommentPayload {commentId, commentContent, commentCreatedAt, commentAuthor} = + Aeson.object + [ "commentId" Aeson..= commentId, + "content" Aeson..= commentContent, + "createdAt" Aeson..= commentCreatedAt, + "author" Aeson..= commentAuthor + ] + +instance FromJSON CommentPayload where + parseJSON = Aeson.withObject "CommentPayload" \o -> do + commentId <- o .: "commentId" + commentContent <- o .: "content" + commentCreatedAt <- o .: "createdAt" + commentAuthor <- o .: "author" + pure CommentPayload {commentId, commentContent, commentCreatedAt, commentAuthor} + +data TicketPayload = TicketPayload + { ticketId :: TicketId, + ticketTitle :: Text, + ticketNumber :: TicketNumber, + ticketDescription :: Maybe Text, + ticketStatus :: TicketStatus, + ticketAuthor :: UserDisplayInfo, + ticketCreatedAt :: UTCTime + } + deriving stock (Show, Eq) + +instance ToJSON TicketPayload where + toJSON TicketPayload {ticketId, ticketTitle, ticketNumber, ticketDescription, ticketStatus, ticketAuthor, ticketCreatedAt} = + Aeson.object + [ "ticketId" Aeson..= ticketId, + "title" Aeson..= ticketTitle, + "number" Aeson..= ticketNumber, + "description" Aeson..= ticketDescription, + "status" Aeson..= ticketStatus, + "author" Aeson..= ticketAuthor, + "createdAt" Aeson..= ticketCreatedAt + ] + +instance FromJSON TicketPayload where + parseJSON = Aeson.withObject "TicketPayload" \o -> do + ticketId <- o .: "ticketId" + ticketTitle <- o .: "title" + ticketNumber <- o .: "number" + ticketDescription <- o .: "description" + ticketStatus <- o .: "status" + ticketAuthor <- o .: "author" + ticketCreatedAt <- o .: "createdAt" + pure TicketPayload {ticketId, ticketTitle, ticketNumber, ticketDescription, ticketStatus, ticketAuthor, ticketCreatedAt} + +data ProjectTicketPayload = ProjectTicketPayload + { projectInfo :: ProjectPayload, + ticketInfo :: TicketPayload + } + deriving stock (Show, Eq) + +instance ToJSON ProjectTicketPayload where + toJSON ProjectTicketPayload {projectInfo, ticketInfo} = + Aeson.object + [ "project" Aeson..= projectInfo, + "ticket" Aeson..= ticketInfo + ] + +instance FromJSON ProjectTicketPayload where + parseJSON = Aeson.withObject "ProjectTicketPayload" \o -> do + projectInfo <- o .: "project" + ticketInfo <- o .: "ticket" + pure ProjectTicketPayload {projectInfo, ticketInfo} + data ContributionPayload = ContributionPayload { contributionId :: ContributionId, contributionNumber :: ContributionNumber, @@ -509,7 +704,8 @@ data ContributionPayload = ContributionPayload contributionStatus :: ContributionStatus, contributionAuthor :: UserDisplayInfo, contributionSourceBranch :: BranchPayload, - contributionTargetBranch :: BranchPayload + contributionTargetBranch :: BranchPayload, + contributionCreatedAt :: UTCTime } deriving stock (Show, Eq) @@ -536,26 +732,27 @@ instance FromJSON ContributionPayload where contributionAuthor <- o .: "author" contributionSourceBranch <- o .: "sourceBranch" contributionTargetBranch <- o .: "targetBranch" - pure ContributionPayload {contributionId, contributionNumber, contributionTitle, contributionDescription, contributionStatus, contributionAuthor, contributionSourceBranch, contributionTargetBranch} + contributionCreatedAt <- o .: "createdAt" + pure ContributionPayload {contributionId, contributionNumber, contributionTitle, contributionDescription, contributionStatus, contributionAuthor, contributionSourceBranch, contributionTargetBranch, contributionCreatedAt} -data ProjectContributionCreatedPayload = ProjectContributionCreatedPayload +data ProjectContributionPayload = ProjectContributionPayload { projectInfo :: ProjectPayload, contributionInfo :: ContributionPayload } deriving stock (Show, Eq) -instance ToJSON ProjectContributionCreatedPayload where - toJSON ProjectContributionCreatedPayload {projectInfo, contributionInfo} = +instance ToJSON ProjectContributionPayload where + toJSON ProjectContributionPayload {projectInfo, contributionInfo} = Aeson.object [ "project" Aeson..= projectInfo, "contribution" Aeson..= contributionInfo ] -instance FromJSON ProjectContributionCreatedPayload where - parseJSON = Aeson.withObject "ProjectContributionCreatedPayload" \o -> do +instance FromJSON ProjectContributionPayload where + parseJSON = Aeson.withObject "ProjectContributionPayload" \o -> do projectInfo <- o .: "project" contributionInfo <- o .: "contribution" - pure ProjectContributionCreatedPayload {projectInfo, contributionInfo} + pure ProjectContributionPayload {projectInfo, contributionInfo} data HydratedEvent = HydratedEvent { hydratedEventPayload :: HydratedEventPayload, @@ -565,12 +762,15 @@ data HydratedEvent = HydratedEvent instance ToJSON HydratedEvent where toJSON he@(HydratedEvent {hydratedEventPayload, hydratedEventLink}) = - let kind :: Text = case hydratedEventTopic he of - ProjectBranchUpdated -> "projectBranchUpdated" - ProjectContributionCreated -> "projectContributionCreated" + let kind = hydratedEventTopic he payload = case hydratedEventPayload of HydratedProjectBranchUpdatedPayload p -> Aeson.toJSON p HydratedProjectContributionCreatedPayload p -> Aeson.toJSON p + HydratedProjectContributionUpdatedPayload p -> Aeson.toJSON p + HydratedProjectContributionCommentPayload p comm -> Aeson.toJSON (p :++ comm) + HydratedProjectTicketCreatedPayload p -> Aeson.toJSON p + HydratedProjectTicketUpdatedPayload p -> Aeson.toJSON p + HydratedProjectTicketCommentPayload p comm -> Aeson.toJSON (p :++ comm) in Aeson.object [ "payload" .= payload, "link" .= URIParam hydratedEventLink, @@ -582,17 +782,36 @@ instance FromJSON HydratedEvent where kind <- o .: "kind" hydratedEventLink <- o .: "link" hydratedEventPayload <- case kind of - "projectBranchUpdated" -> HydratedProjectBranchUpdatedPayload <$> o .: "payload" - "projectContributionCreated" -> HydratedProjectContributionCreatedPayload <$> o .: "payload" - _ -> fail $ "Unknown event kind: " <> Text.unpack kind + ProjectBranchUpdated -> HydratedProjectBranchUpdatedPayload <$> o .: "payload" + ProjectContributionCreated -> HydratedProjectContributionCreatedPayload <$> o .: "payload" + ProjectContributionUpdated -> HydratedProjectContributionUpdatedPayload <$> o .: "payload" + ProjectContributionComment -> do + (p :++ comm) <- o .: "payload" + pure $ HydratedProjectContributionCommentPayload p comm + ProjectTicketCreated -> HydratedProjectTicketCreatedPayload <$> o .: "payload" + ProjectTicketUpdated -> HydratedProjectTicketUpdatedPayload <$> o .: "payload" + ProjectTicketComment -> do + (p :++ comm) <- o .: "payload" + pure $ HydratedProjectTicketCommentPayload p comm + pure HydratedEvent {hydratedEventPayload, hydratedEventLink} data HydratedEventPayload = HydratedProjectBranchUpdatedPayload ProjectBranchUpdatedPayload - | HydratedProjectContributionCreatedPayload ProjectContributionCreatedPayload + | HydratedProjectContributionCreatedPayload ProjectContributionPayload + | HydratedProjectContributionUpdatedPayload ProjectContributionPayload + | HydratedProjectContributionCommentPayload ProjectContributionPayload CommentPayload + | HydratedProjectTicketCreatedPayload ProjectTicketPayload + | HydratedProjectTicketUpdatedPayload ProjectTicketPayload + | HydratedProjectTicketCommentPayload ProjectTicketPayload CommentPayload deriving stock (Show, Eq) hydratedEventTopic :: HydratedEvent -> NotificationTopic hydratedEventTopic (HydratedEvent {hydratedEventPayload}) = case hydratedEventPayload of HydratedProjectBranchUpdatedPayload _ -> ProjectBranchUpdated HydratedProjectContributionCreatedPayload _ -> ProjectContributionCreated + HydratedProjectContributionUpdatedPayload _ -> ProjectContributionUpdated + HydratedProjectContributionCommentPayload _ _ -> ProjectContributionComment + HydratedProjectTicketCreatedPayload _ -> ProjectTicketCreated + HydratedProjectTicketUpdatedPayload _ -> ProjectTicketUpdated + HydratedProjectTicketCommentPayload _ _ -> ProjectTicketComment diff --git a/src/Share/Postgres/Comments/Ops.hs b/src/Share/Postgres/Comments/Ops.hs new file mode 100644 index 00000000..54ccb0c9 --- /dev/null +++ b/src/Share/Postgres/Comments/Ops.hs @@ -0,0 +1,82 @@ +module Share.Postgres.Comments.Ops (createComment) where + +import Data.Time (UTCTime) +import Share.Contribution (Contribution (..)) +import Share.IDs +import Share.Notifications.Queries qualified as NotifsQ +import Share.Notifications.Types (CommentData (..), ContributionData (..), NotificationEvent (..), NotificationEventData (..), TicketData (..)) +import Share.Postgres qualified as PG +import Share.Postgres.Contributions.Queries qualified as ContributionQ +import Share.Postgres.Projects.Queries qualified as ProjectQ +import Share.Postgres.Tickets.Queries qualified as TicketQ +import Share.Prelude +import Share.Ticket (Ticket (..)) +import Share.Web.Share.Comments + +createComment :: + UserId -> + Either ContributionId TicketId -> + Text -> + PG.Transaction e (Comment UserId) +createComment authorId thingId content = do + let (contributionId, ticketId) = case thingId of + Left contributionId -> (Just contributionId, Nothing) + Right ticketId -> (Nothing, Just ticketId) + (commentId, timestamp) <- + PG.queryExpect1Row @(CommentId, UTCTime) + [PG.sql| + INSERT INTO comments(contribution_id, ticket_id, author_id) + VALUES (#{contributionId}, #{ticketId}, #{authorId}) + RETURNING id, created_at + |] + PG.execute_ + [PG.sql| + INSERT INTO comment_revisions(comment_id, revision_number, author_id, content, created_at) + VALUES (#{commentId}, 0, #{authorId}, #{content}, #{timestamp}) + |] + + let commentData = + CommentData + { commentId, + commentAuthorUserId = authorId + } + (event, projectResourceId, projectOwnerUserId) <- case thingId of + Left contributionId -> do + Contribution {projectId, sourceBranchId, targetBranchId, author} <- ContributionQ.contributionById contributionId + (projectData, projectResourceId, projectOwnerUserId) <- ProjectQ.projectNotificationData projectId + let contributionData = + ContributionData + { contributionId, + fromBranchId = sourceBranchId, + toBranchId = targetBranchId, + contributorUserId = author + } + pure (ProjectContributionCommentData projectData contributionData commentData, projectResourceId, projectOwnerUserId) + Right ticketId -> do + Ticket {projectId, author} <- TicketQ.ticketById ticketId + (projectData, projectResourceId, projectOwnerUserId) <- ProjectQ.projectNotificationData projectId + let ticketData = + TicketData + { ticketId, + ticketAuthorUserId = author + } + pure (ProjectTicketCommentData projectData ticketData commentData, projectResourceId, projectOwnerUserId) + let notifEvent = + NotificationEvent + { eventId = (), + eventOccurredAt = (), + eventResourceId = projectResourceId, + eventData = event, + eventScope = projectOwnerUserId, + eventActor = authorId + } + NotifsQ.recordEvent notifEvent + pure $ + Comment + { commentId, + actor = authorId, + timestamp, + editedAt = Nothing, + content, + revision = 0 + } diff --git a/src/Share/Postgres/Comments/Queries.hs b/src/Share/Postgres/Comments/Queries.hs index 61f008e9..a6386fef 100644 --- a/src/Share/Postgres/Comments/Queries.hs +++ b/src/Share/Postgres/Comments/Queries.hs @@ -1,6 +1,5 @@ module Share.Postgres.Comments.Queries ( getComment, - createComment, updateComment, deleteComment, UpdateCommentResult (..), @@ -36,37 +35,6 @@ getComment commentId = do LIMIT 1 |] -createComment :: - UserId -> - Either ContributionId TicketId -> - Text -> - PG.Transaction e (Comment UserId) -createComment authorId thingId content = do - let (contributionId, ticketId) = case thingId of - Left contributionId -> (Just contributionId, Nothing) - Right ticketId -> (Nothing, Just ticketId) - (commentId, timestamp) <- - PG.queryExpect1Row @(CommentId, UTCTime) - [PG.sql| - INSERT INTO comments(contribution_id, ticket_id, author_id) - VALUES (#{contributionId}, #{ticketId}, #{authorId}) - RETURNING id, created_at - |] - PG.execute_ - [PG.sql| - INSERT INTO comment_revisions(comment_id, revision_number, author_id, content, created_at) - VALUES (#{commentId}, 0, #{authorId}, #{content}, #{timestamp}) - |] - pure $ - Comment - { commentId, - actor = authorId, - timestamp, - editedAt = Nothing, - content, - revision = 0 - } - data UpdateCommentResult user = UpdateCommentNotFound | UpdateCommentSuccess (Comment user) diff --git a/src/Share/Postgres/Contributions/Ops.hs b/src/Share/Postgres/Contributions/Ops.hs index cae61538..4c3f8427 100644 --- a/src/Share/Postgres/Contributions/Ops.hs +++ b/src/Share/Postgres/Contributions/Ops.hs @@ -7,9 +7,10 @@ module Share.Postgres.Contributions.Ops (createContribution) where import Share.Contribution (ContributionStatus (..)) import Share.IDs import Share.Notifications.Queries qualified as NotifQ -import Share.Notifications.Types (NotificationEvent (..), NotificationEventData (..), ProjectContributionData (..)) +import Share.Notifications.Types (ContributionData (..), NotificationEvent (..), NotificationEventData (..)) import Share.Postgres qualified as PG import Share.Postgres.Contributions.Queries qualified as ContribQ +import Share.Postgres.Projects.Queries qualified as ProjectQ import Share.Prelude createContribution :: @@ -55,29 +56,21 @@ createContribution authorId projectId title description status sourceBranchId ta RETURNING contributions.id, contributions.contribution_number |] ContribQ.insertContributionStatusChangeEvent contributionId authorId Nothing status - (projectResourceId, projectOwnerUserId, projectPrivate) <- - PG.queryExpect1Row - [PG.sql| - SELECT p.resource_id, p.owner_user_id, p.private - FROM projects p - WHERE p.id = #{projectId} - |] + (projectData, projectResourceId, projectOwnerUserId) <- ProjectQ.projectNotificationData projectId - let contributionEventData = - ProjectContributionData - { projectId, - contributionId, + let contributionData = + ContributionData + { contributionId, fromBranchId = sourceBranchId, toBranchId = targetBranchId, - contributorUserId = authorId, - public = not projectPrivate + contributorUserId = authorId } let notifEvent = NotificationEvent { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, - eventData = ProjectContributionCreatedData contributionEventData, + eventData = ProjectContributionCreatedData projectData contributionData, eventScope = projectOwnerUserId, eventActor = authorId } diff --git a/src/Share/Postgres/Contributions/Queries.hs b/src/Share/Postgres/Contributions/Queries.hs index a66b4372..8fdc7947 100644 --- a/src/Share/Postgres/Contributions/Queries.hs +++ b/src/Share/Postgres/Contributions/Queries.hs @@ -20,6 +20,7 @@ module Share.Postgres.Contributions.Queries getPrecomputedNamespaceDiff, savePrecomputedNamespaceDiff, contributionsRelatedToBranches, + contributionNotificationData, ) where @@ -32,6 +33,7 @@ import Safe (headMay, lastMay) import Share.Codebase.Types (CodebaseEnv (..)) import Share.Contribution (Contribution (..), ContributionStatus (..)) import Share.IDs +import Share.Notifications.Types (ContributionData (..)) import Share.Postgres qualified as PG import Share.Postgres.Comments.Queries (commentsByTicketOrContribution) import Share.Postgres.IDs @@ -572,3 +574,14 @@ contributionsRelatedToBranches branchIds = do ) AND contr.status IN (#{Draft}, #{InReview}) |] + +contributionNotificationData :: ContributionId -> PG.Transaction e ContributionData +contributionNotificationData contributionId = do + Contribution {..} <- contributionById contributionId + pure + ContributionData + { contributionId, + fromBranchId = sourceBranchId, + toBranchId = targetBranchId, + contributorUserId = author + } diff --git a/src/Share/Postgres/Projects/Queries.hs b/src/Share/Postgres/Projects/Queries.hs index e821c9bf..8e84eb36 100644 --- a/src/Share/Postgres/Projects/Queries.hs +++ b/src/Share/Postgres/Projects/Queries.hs @@ -6,12 +6,14 @@ module Share.Postgres.Projects.Queries addProjectRoles, removeProjectRoles, expectProjectShortHandsOf, + projectNotificationData, ) where import Control.Lens import Data.Set qualified as Set import Share.IDs +import Share.Notifications.Types (ProjectData (..)) import Share.Postgres import Share.Postgres qualified as PG import Share.Prelude @@ -110,3 +112,22 @@ expectProjectShortHandsOf trav s = do if length results /= length projIds then error "expectProjectShortHandsOf: Missing expected project short hand" else pure results + +-- | Info used when constructing a notification within a project. +projectNotificationData :: ProjectId -> Transaction e (ProjectData, ResourceId, UserId) +projectNotificationData projectId = do + (resourceId, ownerUserId, isPrivate) <- + PG.queryExpect1Row + [PG.sql| + SELECT p.resource_id, p.owner_user_id, p.private + FROM projects p + WHERE p.id = #{projectId} + |] + pure + ( ProjectData + { projectId, + public = not isPrivate + }, + resourceId, + ownerUserId + ) diff --git a/src/Share/Postgres/Queries.hs b/src/Share/Postgres/Queries.hs index 606bb888..0a818a9f 100644 --- a/src/Share/Postgres/Queries.hs +++ b/src/Share/Postgres/Queries.hs @@ -18,12 +18,13 @@ import Share.Contribution import Share.IDs import Share.IDs qualified as IDs import Share.Notifications.Queries qualified as NotifQ -import Share.Notifications.Types (NotificationEvent (..), NotificationEventData (..), ProjectBranchData (..)) +import Share.Notifications.Types (BranchData (..), NotificationEvent (..), NotificationEventData (..), ProjectData (..)) import Share.OAuth.Types import Share.Postgres (unrecoverableError) import Share.Postgres qualified as PG import Share.Postgres.IDs import Share.Postgres.NameLookups.Types (NameLookupReceipt) +import Share.Postgres.Projects.Queries qualified as ProjectsQ import Share.Postgres.Search.DefinitionSearch.Queries qualified as DDQ import Share.Postgres.Users.Queries qualified as UserQ import Share.Prelude @@ -560,7 +561,7 @@ createBranch :: createBranch !_nlReceipt projectId branchName contributorId causalId mergeTarget creatorId = do branchId <- PG.queryExpect1Col createBranchSQL PG.execute_ (updateReflogSQL branchId ("Branch Created" :: Text)) - recordNotificationEvent branchId + recordNotificationEvent branchId contributorId pure branchId where createBranchSQL = @@ -602,29 +603,20 @@ createBranch !_nlReceipt projectId branchName contributorId causalId mergeTarget WHERE id = #{branchId} |] - recordNotificationEvent :: BranchId -> PG.Transaction e () - recordNotificationEvent branchId = do - (projectId, projectResourceId, projectOwnerUserId, branchContributorUserId, private) <- - PG.queryExpect1Row - [PG.sql| - SELECT p.id, p.resource_id, p.owner_user_id, pb.contributor_id, p.private - FROM project_branches pb - JOIN projects p ON p.id = pb.project_id - WHERE pb.id = #{branchId} - |] - let branchUpdateEventData = - ProjectBranchData - { projectId, - branchId, - branchContributorUserId, - public = not private + recordNotificationEvent :: BranchId -> Maybe UserId -> PG.Transaction e () + recordNotificationEvent branchId branchContributorUserId = do + (projectData, projectResourceId, projectOwnerUserId) <- ProjectsQ.projectNotificationData projectId + let branchData = + BranchData + { branchId, + branchContributorUserId } let notifEvent = NotificationEvent { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, - eventData = ProjectBranchUpdatedData branchUpdateEventData, + eventData = ProjectBranchUpdatedData projectData branchData, eventScope = projectOwnerUserId, eventActor = creatorId } @@ -718,19 +710,22 @@ setBranchCausalHash !_nameLookupReceipt description callerUserId branchId causal JOIN projects p ON p.id = pb.project_id WHERE pb.id = #{branchId} |] - let branchUpdateEventData = - ProjectBranchData + let projectData = + ProjectData { projectId, - branchId, - branchContributorUserId, public = not private } + let branchData = + BranchData + { branchId, + branchContributorUserId + } let notifEvent = NotificationEvent { eventId = (), eventOccurredAt = (), eventResourceId = projectResourceId, - eventData = ProjectBranchUpdatedData branchUpdateEventData, + eventData = ProjectBranchUpdatedData projectData branchData, eventScope = projectOwnerUserId, eventActor = callerUserId } diff --git a/src/Share/Postgres/Tickets/Ops.hs b/src/Share/Postgres/Tickets/Ops.hs new file mode 100644 index 00000000..f07d5589 --- /dev/null +++ b/src/Share/Postgres/Tickets/Ops.hs @@ -0,0 +1,61 @@ +module Share.Postgres.Tickets.Ops (createTicket) where + +import Share.IDs +import Share.Notifications.Queries qualified as NotifQ +import Share.Notifications.Types (NotificationEvent (..), NotificationEventData (..), TicketData (..)) +import Share.Postgres qualified as PG +import Share.Postgres.Projects.Queries qualified as ProjectsQ +import Share.Postgres.Tickets.Queries qualified as TicketQ +import Share.Prelude +import Share.Ticket (TicketStatus) + +createTicket :: + -- | Author + UserId -> + ProjectId -> + -- | Title + Text -> + -- | Description + Maybe Text -> + TicketStatus -> + PG.Transaction e (TicketId, TicketNumber) +createTicket authorId projectId title description status = do + (ticketId, number) <- + PG.queryExpect1Row + [PG.sql| + WITH new_ticket_number AS ( + SELECT (COALESCE(MAX(ticket_number), 0) + 1) AS new + FROM tickets ticket + WHERE ticket.project_id = #{projectId} + ) + INSERT INTO tickets( + author_id, + project_id, + title, + description, + status, + ticket_number + ) + SELECT #{authorId}, #{projectId}, #{title}, #{description}, #{status}::ticket_status, new_ticket_number.new + FROM new_ticket_number + RETURNING tickets.id, tickets.ticket_number + |] + TicketQ.insertTicketStatusChangeEvent ticketId authorId Nothing status + + (projectData, projectResourceId, projectOwnerUserId) <- ProjectsQ.projectNotificationData projectId + let ticketData = + TicketData + { ticketId, + ticketAuthorUserId = authorId + } + let notifEvent = + NotificationEvent + { eventId = (), + eventOccurredAt = (), + eventResourceId = projectResourceId, + eventData = ProjectTicketCreatedData projectData ticketData, + eventScope = projectOwnerUserId, + eventActor = authorId + } + NotifQ.recordEvent notifEvent + pure (ticketId, number) diff --git a/src/Share/Postgres/Tickets/Queries.hs b/src/Share/Postgres/Tickets/Queries.hs index f0799f94..f0bf330f 100644 --- a/src/Share/Postgres/Tickets/Queries.hs +++ b/src/Share/Postgres/Tickets/Queries.hs @@ -3,8 +3,7 @@ {-# LANGUAGE TypeOperators #-} module Share.Postgres.Tickets.Queries - ( createTicket, - ticketByProjectIdAndNumber, + ( ticketByProjectIdAndNumber, shareTicketByProjectIdAndNumber, listTicketsByProjectId, ticketById, @@ -32,40 +31,6 @@ import Share.Web.Share.Comments import Share.Web.Share.Tickets.API import Share.Web.Share.Tickets.Types -createTicket :: - -- | Author - UserId -> - ProjectId -> - -- | Title - Text -> - -- | Description - Maybe Text -> - TicketStatus -> - PG.Transaction e (TicketId, TicketNumber) -createTicket authorId projectId title description status = do - (ticketId, number) <- - PG.queryExpect1Row - [PG.sql| - WITH new_ticket_number AS ( - SELECT (COALESCE(MAX(ticket_number), 0) + 1) AS new - FROM tickets ticket - WHERE ticket.project_id = #{projectId} - ) - INSERT INTO tickets( - author_id, - project_id, - title, - description, - status, - ticket_number - ) - SELECT #{authorId}, #{projectId}, #{title}, #{description}, #{status}::ticket_status, new_ticket_number.new - FROM new_ticket_number - RETURNING tickets.id, tickets.ticket_number - |] - insertTicketStatusChangeEvent ticketId authorId Nothing status - pure (ticketId, number) - ticketByProjectIdAndNumber :: ProjectId -> TicketNumber -> @@ -167,9 +132,9 @@ listTicketsByProjectId projectId limit mayCursor mayUserFilter mayStatusFilter = nextCursor = lastMay items <&> \(ShareTicket {updatedAt, ticketId}) -> Cursor (updatedAt, ticketId) Next in Paged {items, prevCursor, nextCursor} -ticketById :: TicketId -> PG.Transaction e (Maybe Ticket) +ticketById :: (PG.QueryA m) => TicketId -> m Ticket ticketById ticketId = do - PG.query1Row + PG.queryExpect1Row [PG.sql| SELECT ticket.id, @@ -185,19 +150,17 @@ ticketById ticketId = do WHERE ticket.id = #{ticketId} |] -updateTicket :: UserId -> TicketId -> Maybe Text -> NullableUpdate Text -> Maybe TicketStatus -> PG.Transaction e Bool +updateTicket :: UserId -> TicketId -> Maybe Text -> NullableUpdate Text -> Maybe TicketStatus -> PG.Transaction e () updateTicket callerUserId ticketId newTitle newDescription newStatus = do - isJust <$> runMaybeT do - Ticket {..} <- MaybeT $ ticketById ticketId - let updatedTitle = fromMaybe title newTitle - let updatedDescription = fromNullableUpdate description newDescription - let updatedStatus = fromMaybe status newStatus - -- Add a status change event - when (isJust newStatus && newStatus /= Just status) do - lift $ insertTicketStatusChangeEvent ticketId callerUserId (Just status) updatedStatus - lift $ - PG.execute_ - [PG.sql| + Ticket {..} <- ticketById ticketId + let updatedTitle = fromMaybe title newTitle + let updatedDescription = fromNullableUpdate description newDescription + let updatedStatus = fromMaybe status newStatus + -- Add a status change event + when (isJust newStatus && newStatus /= Just status) do + insertTicketStatusChangeEvent ticketId callerUserId (Just status) updatedStatus + PG.execute_ + [PG.sql| UPDATE tickets SET title = #{updatedTitle}, diff --git a/src/Share/Utils/API.hs b/src/Share/Utils/API.hs index 1b434749..3eacc002 100644 --- a/src/Share/Utils/API.hs +++ b/src/Share/Utils/API.hs @@ -288,6 +288,12 @@ instance (Typeable a, Typeable b, ToJSON a, ToJSON b) => ToJSON (a :++ b) where -- If either is not an object, error, showing the type of the non-object value _ -> error $ "Cannot merge JSON representation of " <> show (typeRep (Proxy @a)) <> " with " <> show (typeRep (Proxy @b)) +instance (Typeable a, Typeable b, FromJSON a, FromJSON b) => FromJSON (a :++ b) where + parseJSON v = do + a <- parseJSON v + b <- parseJSON v + pure $ a :++ b + -- | Wrapper useful in combination with `:++` to include the given payload at a specific key. newtype AtKey (key :: Symbol) a = AtKey a diff --git a/src/Share/Web/Share/Comments/Impl.hs b/src/Share/Web/Share/Comments/Impl.hs index 3779c288..6abf1978 100644 --- a/src/Share/Web/Share/Comments/Impl.hs +++ b/src/Share/Web/Share/Comments/Impl.hs @@ -10,6 +10,7 @@ import Share.IDs import Share.IDs qualified as IDs import Share.OAuth.Session import Share.Postgres qualified as PG +import Share.Postgres.Comments.Ops qualified as CommentOps import Share.Postgres.Comments.Queries qualified as CommentsQ import Share.Postgres.Queries qualified as Q import Share.Postgres.Users.Queries qualified as UserQ @@ -37,7 +38,7 @@ createCommentEndpoint session userHandle projectSlug contributionOrTicketId (Cre _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkCommentCreate callerUserId projectId PG.runTransaction $ do - commentEvent <- CommentsQ.createComment callerUserId contributionOrTicketId content + commentEvent <- CommentOps.createComment callerUserId contributionOrTicketId content UserQ.userDisplayInfoOf traversed (CommentEvent commentEvent) where projectShorthand = IDs.ProjectShortHand {userHandle, projectSlug} diff --git a/src/Share/Web/Share/Tickets/Impl.hs b/src/Share/Web/Share/Tickets/Impl.hs index f37b6ead..90d9e808 100644 --- a/src/Share/Web/Share/Tickets/Impl.hs +++ b/src/Share/Web/Share/Tickets/Impl.hs @@ -17,6 +17,7 @@ import Share.IDs qualified as IDs import Share.OAuth.Session import Share.Postgres qualified as PG import Share.Postgres.Queries qualified as Q +import Share.Postgres.Tickets.Ops qualified as TicketOps import Share.Postgres.Tickets.Queries qualified as TicketsQ import Share.Postgres.Users.Queries qualified as UserQ import Share.Prelude @@ -108,7 +109,7 @@ createTicketEndpoint session userHandle projectSlug (CreateTicketRequest {title, pure project _authReceipt <- AuthZ.permissionGuard $ AuthZ.checkTicketCreate callerUserId projectId PG.runTransactionOrRespondError $ do - (_, ticketNumber) <- TicketsQ.createTicket callerUserId projectId title description Open + (_, ticketNumber) <- TicketOps.createTicket callerUserId projectId title description Open TicketsQ.shareTicketByProjectIdAndNumber projectId ticketNumber `whenNothingM` throwError (InternalServerError "create-ticket-error" internalServerError) >>= UserQ.userDisplayInfoOf traverse where diff --git a/src/Share/Web/UI/Links.hs b/src/Share/Web/UI/Links.hs index 222f8228..456242ca 100644 --- a/src/Share/Web/UI/Links.hs +++ b/src/Share/Web/UI/Links.hs @@ -107,6 +107,21 @@ contributionLink (ProjectShortHand {userHandle, projectSlug}) contributionNumber let path = [IDs.toText (PrefixedID @"@" userHandle), IDs.toText projectSlug, "contributions", IDs.toText contributionNumber] shareUIPath path +contributionCommentLink :: (MonadReader (Env.Env ctx) m) => ProjectShortHand -> ContributionNumber -> CommentId -> m URI +contributionCommentLink (ProjectShortHand {userHandle, projectSlug}) contributionNumber _commentId = do + let path = [IDs.toText (PrefixedID @"@" userHandle), IDs.toText projectSlug, "contributions", IDs.toText contributionNumber] + shareUIPath path + +ticketLink :: (MonadReader (Env.Env ctx) m) => ProjectShortHand -> TicketNumber -> m URI +ticketLink (ProjectShortHand {userHandle, projectSlug}) ticketNumber = do + let path = [IDs.toText (PrefixedID @"@" userHandle), IDs.toText projectSlug, "tickets", IDs.toText ticketNumber] + shareUIPath path + +ticketCommentLink :: (MonadReader (Env.Env ctx) m) => ProjectShortHand -> TicketNumber -> CommentId -> m URI +ticketCommentLink (ProjectShortHand {userHandle, projectSlug}) ticketNumber _commentId = do + let path = [IDs.toText (PrefixedID @"@" userHandle), IDs.toText projectSlug, "tickets", IDs.toText ticketNumber] + shareUIPath path + -- | Where the user should go when clicking on a notification notificationLink :: (MonadReader (Env.Env ctx) m) => HydratedEventPayload -> m URI notificationLink = \case @@ -114,6 +129,16 @@ notificationLink = \case projectBranchBrowseLink payload.branchInfo.projectBranchShortHand HydratedProjectContributionCreatedPayload payload -> contributionLink payload.projectInfo.projectShortHand payload.contributionInfo.contributionNumber + HydratedProjectContributionUpdatedPayload payload -> + contributionLink payload.projectInfo.projectShortHand payload.contributionInfo.contributionNumber + HydratedProjectContributionCommentPayload payload comment -> + contributionCommentLink payload.projectInfo.projectShortHand payload.contributionInfo.contributionNumber comment.commentId + HydratedProjectTicketCreatedPayload payload -> + ticketLink payload.projectInfo.projectShortHand payload.ticketInfo.ticketNumber + HydratedProjectTicketUpdatedPayload payload -> + ticketLink payload.projectInfo.projectShortHand payload.ticketInfo.ticketNumber + HydratedProjectTicketCommentPayload payload comment -> + ticketCommentLink payload.projectInfo.projectShortHand payload.ticketInfo.ticketNumber comment.commentId unisonLogoImage :: URI unisonLogoImage = diff --git a/transcripts/share-apis/notifications/contribution-comment-create.json b/transcripts/share-apis/notifications/contribution-comment-create.json new file mode 100644 index 00000000..338ffee2 --- /dev/null +++ b/transcripts/share-apis/notifications/contribution-comment-create.json @@ -0,0 +1,21 @@ +{ + "body": { + "actor": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "content": "This is a new comment", + "editedAt": null, + "id": "CMT-", + "kind": "comment", + "revision": 0, + "timestamp": "" + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/list-notifications-read-transcripts.json b/transcripts/share-apis/notifications/list-notifications-read-transcripts.json index 722710de..aa84227e 100644 --- a/transcripts/share-apis/notifications/list-notifications-read-transcripts.json +++ b/transcripts/share-apis/notifications/list-notifications-read-transcripts.json @@ -14,7 +14,7 @@ "kind": "user" }, "data": { - "kind": "projectBranchUpdated", + "kind": "project:branch:updated", "link": "http://:1234/@test/publictestproject/code/newbranch/latest", "payload": { "branch": { diff --git a/transcripts/share-apis/notifications/list-notifications-test.json b/transcripts/share-apis/notifications/list-notifications-test.json new file mode 100644 index 00000000..91c64b1d --- /dev/null +++ b/transcripts/share-apis/notifications/list-notifications-test.json @@ -0,0 +1,277 @@ +{ + "body": { + "items": [ + { + "createdAt": "", + "event": { + "actor": { + "info": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "kind": "user" + }, + "data": { + "kind": "project:ticket:comment", + "link": "http://:1234/@test/publictestproject/tickets/1", + "payload": { + "author": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "commentId": "CMT-", + "content": "This is a new comment on the ticket", + "createdAt": "", + "project": { + "projectId": "P-", + "projectOwnerHandle": "test", + "projectOwnerUserId": "U-", + "projectShortHand": "@test/publictestproject", + "projectSlug": "publictestproject" + }, + "ticket": { + "author": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "createdAt": "", + "description": "I want the code to solve all my problems but it does not. Please fix.\n\n## Things I need:\n\n* It should tie my *shoes*\n* It should make me _coffee_\n* It should do my taxes\n", + "number": 1, + "status": "open", + "ticketId": "T-", + "title": "Bug Report" + } + } + }, + "id": "EVENT-", + "occurredAt": "", + "scope": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + } + }, + "id": "NOT-", + "status": "unread" + }, + { + "createdAt": "", + "event": { + "actor": { + "info": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "kind": "user" + }, + "data": { + "kind": "project:ticket:created", + "link": "http://:1234/@test/publictestproject/tickets/3", + "payload": { + "project": { + "projectId": "P-", + "projectOwnerHandle": "test", + "projectOwnerUserId": "U-", + "projectShortHand": "@test/publictestproject", + "projectSlug": "publictestproject" + }, + "ticket": { + "author": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "createdAt": "", + "description": "My description", + "number": 3, + "status": "open", + "ticketId": "T-", + "title": "My Ticket" + } + } + }, + "id": "EVENT-", + "occurredAt": "", + "scope": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + } + }, + "id": "NOT-", + "status": "unread" + }, + { + "createdAt": "", + "event": { + "actor": { + "info": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "kind": "user" + }, + "data": { + "kind": "project:contribution:comment", + "link": "http://:1234/@test/publictestproject/contributions/1", + "payload": { + "author": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "commentId": "CMT-", + "content": "This is a new comment", + "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": "in_review", + "targetBranch": { + "branchContributorHandle": "transcripts", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "contribution", + "branchShortHand": "@transcripts/contribution", + "projectBranchShortHand": "@test/publictestproject/@transcripts/contribution" + }, + "title": "Fix issue with user authentication" + }, + "createdAt": "", + "project": { + "projectId": "P-", + "projectOwnerHandle": "test", + "projectOwnerUserId": "U-", + "projectShortHand": "@test/publictestproject", + "projectSlug": "publictestproject" + } + } + }, + "id": "EVENT-", + "occurredAt": "", + "scope": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + } + }, + "id": "NOT-", + "status": "unread" + }, + { + "createdAt": "", + "event": { + "actor": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + }, + "data": { + "kind": "project:contribution:created", + "link": "http://:1234/@test/publictestproject/contributions/3", + "payload": { + "contribution": { + "author": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "contributionId": "C-", + "description": "My description", + "number": 3, + "sourceBranch": { + "branchContributorHandle": null, + "branchContributorUserId": null, + "branchId": "B-", + "branchName": "main", + "branchShortHand": "main", + "projectBranchShortHand": "@test/publictestproject/main" + }, + "status": "draft", + "targetBranch": { + "branchContributorHandle": null, + "branchContributorUserId": null, + "branchId": "B-", + "branchName": "feature", + "branchShortHand": "feature", + "projectBranchShortHand": "@test/publictestproject/feature" + }, + "title": "My contribution" + }, + "project": { + "projectId": "P-", + "projectOwnerHandle": "test", + "projectOwnerUserId": "U-", + "projectShortHand": "@test/publictestproject", + "projectSlug": "publictestproject" + } + } + }, + "id": "EVENT-", + "occurredAt": "", + "scope": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + } + }, + "id": "NOT-", + "status": "unread" + } + ], + "nextCursor": "", + "prevCursor": "" + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/list-notifications-transcripts.json b/transcripts/share-apis/notifications/list-notifications-transcripts.json index 4fafdc6d..3012b6db 100644 --- a/transcripts/share-apis/notifications/list-notifications-transcripts.json +++ b/transcripts/share-apis/notifications/list-notifications-transcripts.json @@ -14,7 +14,7 @@ "kind": "user" }, "data": { - "kind": "projectBranchUpdated", + "kind": "project:branch:updated", "link": "http://:1234/@test/publictestproject/code/newbranch/latest", "payload": { "branch": { @@ -62,7 +62,7 @@ "kind": "user" }, "data": { - "kind": "projectContributionCreated", + "kind": "project:contribution:created", "link": "http://:1234/@test/publictestproject/contributions/3", "payload": { "contribution": { diff --git a/transcripts/share-apis/notifications/list-notifications-unread-test.json b/transcripts/share-apis/notifications/list-notifications-unread-test.json index ae1d216c..319db18b 100644 --- a/transcripts/share-apis/notifications/list-notifications-unread-test.json +++ b/transcripts/share-apis/notifications/list-notifications-unread-test.json @@ -1,8 +1,210 @@ { "body": { - "items": [], - "nextCursor": null, - "prevCursor": null + "items": [ + { + "createdAt": "", + "event": { + "actor": { + "info": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "kind": "user" + }, + "data": { + "kind": "project:ticket:created", + "link": "http://:1234/@test/publictestproject/tickets/3", + "payload": { + "project": { + "projectId": "P-", + "projectOwnerHandle": "test", + "projectOwnerUserId": "U-", + "projectShortHand": "@test/publictestproject", + "projectSlug": "publictestproject" + }, + "ticket": { + "author": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "createdAt": "", + "description": "My description", + "number": 3, + "status": "open", + "ticketId": "T-", + "title": "My Ticket" + } + } + }, + "id": "EVENT-", + "occurredAt": "", + "scope": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + } + }, + "id": "NOT-", + "status": "unread" + }, + { + "createdAt": "", + "event": { + "actor": { + "info": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "kind": "user" + }, + "data": { + "kind": "project:contribution:comment", + "link": "http://:1234/@test/publictestproject/contributions/1", + "payload": { + "author": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "commentId": "CMT-", + "content": "This is a new comment", + "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": "in_review", + "targetBranch": { + "branchContributorHandle": "transcripts", + "branchContributorUserId": "U-", + "branchId": "B-", + "branchName": "contribution", + "branchShortHand": "@transcripts/contribution", + "projectBranchShortHand": "@test/publictestproject/@transcripts/contribution" + }, + "title": "Fix issue with user authentication" + }, + "createdAt": "", + "project": { + "projectId": "P-", + "projectOwnerHandle": "test", + "projectOwnerUserId": "U-", + "projectShortHand": "@test/publictestproject", + "projectSlug": "publictestproject" + } + } + }, + "id": "EVENT-", + "occurredAt": "", + "scope": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + } + }, + "id": "NOT-", + "status": "unread" + }, + { + "createdAt": "", + "event": { + "actor": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + }, + "data": { + "kind": "project:contribution:created", + "link": "http://:1234/@test/publictestproject/contributions/3", + "payload": { + "contribution": { + "author": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "contributionId": "C-", + "description": "My description", + "number": 3, + "sourceBranch": { + "branchContributorHandle": null, + "branchContributorUserId": null, + "branchId": "B-", + "branchName": "main", + "branchShortHand": "main", + "projectBranchShortHand": "@test/publictestproject/main" + }, + "status": "draft", + "targetBranch": { + "branchContributorHandle": null, + "branchContributorUserId": null, + "branchId": "B-", + "branchName": "feature", + "branchShortHand": "feature", + "projectBranchShortHand": "@test/publictestproject/feature" + }, + "title": "My contribution" + }, + "project": { + "projectId": "P-", + "projectOwnerHandle": "test", + "projectOwnerUserId": "U-", + "projectShortHand": "@test/publictestproject", + "projectSlug": "publictestproject" + } + } + }, + "id": "EVENT-", + "occurredAt": "", + "scope": { + "info": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + }, + "kind": "user" + } + }, + "id": "NOT-", + "status": "unread" + } + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/notifications/run.zsh b/transcripts/share-apis/notifications/run.zsh index 551a6ada..b355ae07 100755 --- a/transcripts/share-apis/notifications/run.zsh +++ b/transcripts/share-apis/notifications/run.zsh @@ -58,6 +58,26 @@ fetch "$test_user" POST public-contribution-create '/users/test/projects/publict "targetBranchRef": "main" }' +# Add a comment to a contribution, which should trigger a notification for the test user, which +# follows "watch_project" +fetch "$transcripts_user" POST contribution-comment-create '/users/test/projects/publictestproject/contributions/1/timeline/comments' '{ + "content": "This is a new comment" +}' + +# Create a ticket in the public project, which should trigger a notification for the test user, which +# follows "watch_project" + +fetch "$transcripts_user" POST ticket-create '/users/test/projects/publictestproject/tickets' '{ + "title": "My Ticket", + "description": "My description" +}' + +# Add a comment to the ticket, which should trigger a notification for the test user, which +# follows "watch_project" +fetch "$transcripts_user" POST ticket-comment-create '/users/test/projects/publictestproject/tickets/1/timeline/comments' '{ + "content": "This is a new comment on the ticket" +}' + # Create a contribution in a private project, which shouldn't create a notification for either, because 'test' has # a subscription filter and 'transcripts' doesn't have access. fetch "$test_user" POST private-contribution-create '/users/test/projects/privatetestproject/contributions' '{ @@ -82,6 +102,8 @@ fetch "$unauthorized_user" GET notifications-get-unauthorized '/users/test/notif test_notification_id=$(fetch_data_jq "$test_user" GET list-notifications-test '/users/test/notifications/hub' '.items[0].id') transcripts_notification_id=$(fetch_data_jq "$transcripts_user" GET list-notifications-transcripts '/users/transcripts/notifications/hub' '.items[0].id') +fetch "$test_user" GET list-notifications-test '/users/test/notifications/hub' + fetch "$transcripts_user" GET list-notifications-transcripts '/users/transcripts/notifications/hub' # Mark notifications as read diff --git a/transcripts/share-apis/notifications/ticket-comment-create.json b/transcripts/share-apis/notifications/ticket-comment-create.json new file mode 100644 index 00000000..8daa7d92 --- /dev/null +++ b/transcripts/share-apis/notifications/ticket-comment-create.json @@ -0,0 +1,21 @@ +{ + "body": { + "actor": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "content": "This is a new comment on the ticket", + "editedAt": null, + "id": "CMT-", + "kind": "comment", + "revision": 0, + "timestamp": "" + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/ticket-create.json b/transcripts/share-apis/notifications/ticket-create.json new file mode 100644 index 00000000..639865bf --- /dev/null +++ b/transcripts/share-apis/notifications/ticket-create.json @@ -0,0 +1,24 @@ +{ + "body": { + "author": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "Transcript User", + "userId": "U-" + }, + "createdAt": "", + "description": "My description", + "id": "T-", + "numComments": 0, + "number": 3, + "projectRef": "@test/publictestproject", + "status": "open", + "title": "My Ticket", + "updatedAt": "" + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/notifications/webhook_results.txt b/transcripts/share-apis/notifications/webhook_results.txt index a246f9e3..5f39644b 100644 --- a/transcripts/share-apis/notifications/webhook_results.txt +++ b/transcripts/share-apis/notifications/webhook_results.txt @@ -1,3 +1,3 @@ -Successful webhooks: 1 -Unsuccessful webhooks: 1 +Successful webhooks: 4 +Unsuccessful webhooks: 4