Skip to content

Implement Ticket Created and New Comment notifications #89

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions share-api.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions sql/2025-06-02_comment-content.sql
Original file line number Diff line number Diff line change
@@ -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
;
44 changes: 44 additions & 0 deletions sql/2025-06-03_more-notification-topics.sql
Original file line number Diff line number Diff line change
@@ -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;

127 changes: 85 additions & 42 deletions src/Share/BackgroundJobs/Webhooks/Worker.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
83 changes: 76 additions & 7 deletions src/Share/Notifications/Queries.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Loading
Loading