Skip to content

Add endpoints for subscribing to a project #91

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 7 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
19 changes: 19 additions & 0 deletions sql/2025-06-04_watch-projects.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Migration to add watch_project subscriptions for all existing users and projects.

INSERT INTO notification_subscriptions (subscriber_user_id, scope_user_id, topics, topic_groups, filter)
SELECT u.id,
p.owner_user_id,
'{}',
ARRAY['watch_project'::notification_topic_group]::notification_topic_group[],
jsonb_build_object('projectId', p.id)
FROM users u
JOIN projects p ON u.id = p.owner_user_id
WHERE NOT EXISTS (
SELECT FROM notification_subscriptions ns
WHERE ns.subscriber_user_id = u.id
AND ns.scope_user_id = p.owner_user_id
AND ns.topics = '{}'
AND ns.topic_groups = ARRAY['watch_project'::notification_topic_group]
AND ns.filter = jsonb_build_object('projectId', p.id)
)
;
46 changes: 46 additions & 0 deletions src/Share/Notifications/Queries.hs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ module Share.Notifications.Queries
getNotificationSubscription,
hydrateEventPayload,
hasUnreadNotifications,
updateWatchProjectSubscription,
isUserSubscribedToWatchProject,
)
where

import Control.Lens
import Data.Aeson qualified as Aeson
import Data.Foldable qualified as Foldable
import Data.Ord (clamp)
import Data.Set qualified as Set
import Data.Set.NonEmpty (NESet)
import Share.Contribution
import Share.IDs
Expand Down Expand Up @@ -464,3 +468,45 @@ hydrateEventPayload = \case
|]
)
<*> (UsersQ.userDisplayInfoOf id commentAuthorUserId)

-- | Subscribe or unsubscribe to watching a project
updateWatchProjectSubscription :: UserId -> ProjectId -> Bool -> Transaction e (Maybe NotificationSubscriptionId)
updateWatchProjectSubscription userId projId shouldBeSubscribed = do
let filter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId]
Copy link
Preview

Copilot AI Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The local variable name filter shadows Prelude.filter, which can be confusing. Consider renaming it to subscriptionFilter for clarity.

Suggested change
let filter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId]
let subscriptionFilter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId]

Copilot uses AI. Check for mistakes.

existing <- isUserSubscribedToWatchProject userId projId
case existing of
Just existingId
| not shouldBeSubscribed -> do
execute_
[sql|
DELETE FROM notification_subscriptions
WHERE id = #{existingId}
AND subscriber_user_id = #{userId}
|]
pure Nothing
| otherwise -> pure (Just existingId)
Nothing | shouldBeSubscribed -> do
-- Create a new subscription
projectOwnerUserId <-
queryExpect1Col
[sql|
SELECT p.owner_user_id
FROM projects p
WHERE p.id = #{projId}
|]
Just <$> createNotificationSubscription userId projectOwnerUserId mempty (Set.singleton WatchProject) (Just filter)
_ -> pure Nothing

isUserSubscribedToWatchProject :: UserId -> ProjectId -> Transaction e (Maybe NotificationSubscriptionId)
isUserSubscribedToWatchProject userId projId = do
let filter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId]
query1Col @NotificationSubscriptionId
[sql|
SELECT ns.id FROM notification_subscriptions ns
JOIN projects p ON p.id = #{projId}
WHERE ns.subscriber_user_id = #{userId}
AND ns.scope_user_id = p.owner_user_id
AND ns.topic_groups = ARRAY[#{WatchProject}::notification_topic_group]
AND ns.filter = #{filter}::jsonb
LIMIT 1
|]
10 changes: 7 additions & 3 deletions src/Share/Postgres/Ops.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Control.Monad.Except
import Servant
import Share.IDs (ProjectId, ProjectSlug (..), UserHandle (..), UserId (..))
import Share.IDs qualified as IDs
import Share.Notifications.Queries qualified as Notifs
import Share.Postgres qualified as PG
import Share.Postgres.Queries as Q
import Share.Postgres.Users.Queries qualified as UserQ
Expand Down Expand Up @@ -46,9 +47,12 @@ projectIdByUserHandleAndSlug :: UserHandle -> ProjectSlug -> WebApp ProjectId
projectIdByUserHandleAndSlug userHandle projectSlug = do
PG.runTransaction (Q.projectIDFromHandleAndSlug userHandle projectSlug) `or404` (EntityMissing (ErrorID "no-project-for-handle-and-slug") $ "Project not found: " <> IDs.toText userHandle <> "/" <> IDs.toText projectSlug)

createProject :: UserId -> ProjectSlug -> Maybe Text -> Set ProjectTag -> ProjectVisibility -> WebApp ProjectId
createProject ownerUserId slug summary tags visibility = do
createProject :: UserId -> UserId -> ProjectSlug -> Maybe Text -> Set ProjectTag -> ProjectVisibility -> WebApp ProjectId
createProject caller ownerUserId slug summary tags visibility = do
PG.runTransactionOrRespondError do
Q.projectIDFromUserIdAndSlug ownerUserId slug >>= \case
Just projectId -> throwError (ProjectAlreadyExists ownerUserId slug projectId)
Nothing -> Q.createProject ownerUserId slug summary tags visibility
Nothing -> do
projId <- Q.createProject ownerUserId slug summary tags visibility
_ <- Notifs.updateWatchProjectSubscription caller projId True
pure projId
5 changes: 2 additions & 3 deletions src/Share/Web/Authorization.hs
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,8 @@ checkDownloadFromProjectBranchCodebase :: WebApp (Either AuthZFailure AuthZ.Auth
checkDownloadFromProjectBranchCodebase =
pure . Right $ AuthZ.UnsafeAuthZReceipt Nothing

checkProjectCreate :: Maybe UserId -> UserId -> WebApp (Either AuthZFailure AuthZ.AuthZReceipt)
checkProjectCreate mayReqUserId targetUserId = maybePermissionFailure (ProjectPermission (ProjectCreate targetUserId)) $ do
reqUserId <- guardMaybe mayReqUserId
checkProjectCreate :: UserId -> UserId -> WebApp (Either AuthZFailure AuthZ.AuthZReceipt)
checkProjectCreate reqUserId targetUserId = maybePermissionFailure (ProjectPermission (ProjectCreate targetUserId)) $ do
-- Can create projects in their own user, or in any org they have permission to create projects in.
guard (reqUserId == targetUserId) <|> checkCreateInOrg reqUserId
pure $ AuthZ.UnsafeAuthZReceipt Nothing
Expand Down
25 changes: 25 additions & 0 deletions src/Share/Web/Authorization/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ module Share.Web.Authorization.Types
RemoveRolesResponse (..),
AddRolesRequest (..),
RemoveRolesRequest (..),
ProjectNotificationSubscriptionRequest (..),
ProjectNotificationSubscriptionResponse (..),

-- * AuthZReceipt
AuthZReceipt (..),
Expand Down Expand Up @@ -434,3 +436,26 @@ data AuthZReceipt = UnsafeAuthZReceipt {getCacheability :: Maybe CachingToken}
-- | Requests should only be cached if they're for a public endpoint.
-- Obtaining a caching token is proof that the resource was public and can be cached.
data CachingToken = CachingToken

data ProjectNotificationSubscriptionRequest
= ProjectNotificationSubscriptionRequest
{ isSubscribed :: Bool
}
deriving (Show, Eq)

instance FromJSON ProjectNotificationSubscriptionRequest where
parseJSON = Aeson.withObject "ProjectNotificationSubscriptionRequest" $ \o -> do
isSubscribed <- o Aeson..: "isSubscribed"
pure ProjectNotificationSubscriptionRequest {isSubscribed}

data ProjectNotificationSubscriptionResponse
= ProjectNotificationSubscriptionResponse
{ subscriptionId :: Maybe NotificationSubscriptionId
}
deriving (Show, Eq)

instance ToJSON ProjectNotificationSubscriptionResponse where
toJSON ProjectNotificationSubscriptionResponse {..} =
object
[ "subscriptionId" .= subscriptionId
]
7 changes: 6 additions & 1 deletion src/Share/Web/Share/Projects/API.hs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ type ProjectResourceAPI =
:<|> GetProjectEndpoint
:<|> ( "fav" :> FavProjectEndpoint
)
:<|> "roles" :> MaintainersResourceAPI
:<|> ("roles" :> MaintainersResourceAPI)
:<|> ("subscription" :> ProjectNotificationSubscriptionEndpoint)
)

type ProjectDiffNamespacesEndpoint =
Expand Down Expand Up @@ -111,3 +112,7 @@ type RemoveRolesEndpoint =
:>
-- Return the updated list of maintainers
Delete '[JSON] RemoveRolesResponse

type ProjectNotificationSubscriptionEndpoint =
ReqBody '[JSON] ProjectNotificationSubscriptionRequest
:> Put '[JSON] ProjectNotificationSubscriptionResponse
31 changes: 26 additions & 5 deletions src/Share/Web/Share/Projects/Impl.hs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Share.Codebase qualified as Codebase
import Share.Env qualified as Env
import Share.IDs (PrefixedHash (..), ProjectSlug (..), UserHandle, UserId)
import Share.IDs qualified as IDs
import Share.Notifications.Queries qualified as NotifsQ
import Share.OAuth.Session
import Share.Postgres qualified as PG
import Share.Postgres.Authorization.Queries qualified as AuthZQ
Expand Down Expand Up @@ -118,6 +119,7 @@ projectServer session handle =
:<|> getProjectEndpoint session handle slug
:<|> favProjectEndpoint session handle slug
:<|> maintainersResourceServer slug
:<|> projectNotificationSubscriptionEndpoint session handle slug
)
where
addTags :: forall x. ProjectSlug -> WebApp x -> WebApp x
Expand Down Expand Up @@ -308,11 +310,12 @@ namespaceHashForBranchOrRelease authZReceipt Project {projectId, ownerUserId = p
pure (codebase, causalId, branchHashId)

createProjectEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> CreateProjectRequest -> WebApp CreateProjectResponse
createProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle projectSlug req = do
createProjectEndpoint mayCaller userHandle projectSlug req = do
callerUserId <- AuthN.requireAuthenticatedUser mayCaller
User {user_id = targetUserId} <- PGO.expectUserByHandle userHandle
AuthZ.permissionGuard $ AuthZ.checkProjectCreate callerUserId targetUserId
let CreateProjectRequest {summary, tags, visibility} = req
projectId <- PGO.createProject targetUserId projectSlug summary tags visibility
projectId <- PGO.createProject callerUserId targetUserId projectSlug summary tags visibility
addRequestTag "project-id" (IDs.toText projectId)
pure CreateProjectResponse

Expand Down Expand Up @@ -344,17 +347,23 @@ getProjectEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> WebApp GetPr
getProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle projectSlug = do
projectId <- PG.runTransaction (Q.projectIDFromHandleAndSlug userHandle projectSlug) `or404` (EntityMissing (ErrorID "project:missing") "Project not found")
addRequestTag "project-id" (IDs.toText projectId)
(releaseDownloads, (project, favData, projectOwner, defaultBranch, latestRelease), contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo) <- PG.runTransactionOrRespondError do
(releaseDownloads, (project, favData, projectOwner, defaultBranch, latestRelease), contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo, isUserWatchingProject) <- PG.runTransactionOrRespondError do
projectWithMeta <- (Q.projectByIdWithMetadata callerUserId projectId) `whenNothingM` throwError (EntityMissing (ErrorID "project:missing") "Project not found")
releaseDownloads <- Q.releaseDownloadStatsForProject projectId
contributionStats <- Q.contributionStatsForProject projectId
ticketStats <- Q.ticketStatsForProject projectId
permissionsForProject <- PermissionsInfo <$> AuthZQ.permissionsForProject callerUserId projectId
isPremiumProjectInfo <- IsPremiumProject <$> ProjectsQ.isPremiumProject projectId
pure (releaseDownloads, projectWithMeta, contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo)
isUserWatchingProject <- case callerUserId of
Nothing -> pure $ IsSubscribed False
Just caller -> do
NotifsQ.isUserSubscribedToWatchProject caller projectId >>= \case
Just _ -> pure $ IsSubscribed True
Nothing -> pure $ IsSubscribed False
pure (releaseDownloads, projectWithMeta, contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo, isUserWatchingProject)
let releaseDownloadStats = ReleaseDownloadStats {releaseDownloads}
AuthZ.permissionGuard $ AuthZ.checkProjectGet callerUserId projectId
pure (projectToAPI projectOwner project :++ favData :++ APIProjectBranchAndReleaseDetails {defaultBranch, latestRelease} :++ releaseDownloadStats :++ contributionStats :++ ticketStats :++ permissionsForProject :++ isPremiumProjectInfo)
pure (projectToAPI projectOwner project :++ favData :++ APIProjectBranchAndReleaseDetails {defaultBranch, latestRelease} :++ releaseDownloadStats :++ contributionStats :++ ticketStats :++ permissionsForProject :++ isPremiumProjectInfo :++ isUserWatchingProject)

favProjectEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> FavProjectRequest -> WebApp NoContent
favProjectEndpoint sess userHandle projectSlug (FavProjectRequest {isFaved}) = do
Expand Down Expand Up @@ -406,3 +415,15 @@ removeRolesEndpoint session projectUserHandle projectSlug (RemoveRolesRequest {r
updatedRoles <- ProjectsQ.removeProjectRoles projectId roleAssignments
roleAssignments <- canonicalRoleAssignmentOrdering <$> displaySubjectsOf (traversed . traversed) updatedRoles
pure $ RemoveRolesResponse {roleAssignments}

projectNotificationSubscriptionEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> ProjectNotificationSubscriptionRequest -> WebApp ProjectNotificationSubscriptionResponse
projectNotificationSubscriptionEndpoint session projectUserHandler projectSlug (ProjectNotificationSubscriptionRequest {isSubscribed}) = do
caller <- AuthN.requireAuthenticatedUser session
projectId <- PG.runTransactionOrRespondError $ do
Q.projectIDFromHandleAndSlug projectUserHandler projectSlug `whenNothingM` throwError (EntityMissing (ErrorID "project:missing") "Project not found")
_authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkSubscriptionsManage caller caller
maySubscriptionId <- PG.runTransaction $ NotifsQ.updateWatchProjectSubscription caller projectId isSubscribed
pure $
ProjectNotificationSubscriptionResponse
{ subscriptionId = maySubscriptionId
}
8 changes: 8 additions & 0 deletions src/Share/Web/Share/Projects/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ module Share.Web.Share.Projects.Types
IsPremiumProject (..),
APIProjectBranchAndReleaseDetails (..),
PermissionsInfo (..),
IsSubscribed (..),
)
where

Expand Down Expand Up @@ -223,6 +224,12 @@ newtype IsPremiumProject = IsPremiumProject
deriving (Show)
deriving (ToJSON) via (AtKey "isPremiumProject" Bool)

newtype IsSubscribed = IsSubscribed
{ isSubscribed :: Bool
}
deriving (Show)
deriving (ToJSON) via (AtKey "isSubscribed" Bool)

type GetProjectResponse =
APIProject
:++ FavData
Expand All @@ -232,6 +239,7 @@ type GetProjectResponse =
:++ TicketStats
:++ PermissionsInfo
:++ IsPremiumProject
:++ IsSubscribed

data ListProjectsResponse = ListProjectsResponse
{ projects :: [APIProject :++ FavData]
Expand Down
5 changes: 3 additions & 2 deletions src/Share/Web/UCM/Projects/Impl.hs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ getProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) mayUcmProjectId mayUcm
lift $ parseParam @ProjectId "id" ucmProjectId

createProjectEndpoint :: Maybe Session -> UCMProjects.CreateProjectRequest -> WebApp UCMProjects.CreateProjectResponse
createProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) (UCMProjects.CreateProjectRequest {projectName}) = toResponse do
createProjectEndpoint caller (UCMProjects.CreateProjectRequest {projectName}) = toResponse do
callerUserId <- lift $ AuthN.requireAuthenticatedUser caller
ProjectShortHand {userHandle, projectSlug} <- lift $ parseParam @ProjectShortHand "projectName" projectName
(User {user_id = targetUserId}, mayOrg) <- pgT do
user@(User {user_id}) <- UserQ.userByHandle userHandle `orThrow` UCMProjects.CreateProjectResponseNotFound (UCMProjects.NotFound "User not found")
Expand All @@ -99,7 +100,7 @@ createProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) (UCMProjects.Create
Just (Org {isCommercial}) -> if isCommercial then ProjectPrivate else ProjectPublic
let summary = Nothing
let tags = mempty
projectId <- lift $ PGO.createProject targetUserId projectSlug summary tags visibility
projectId <- lift $ PGO.createProject callerUserId targetUserId projectSlug summary tags visibility
let apiProject = UCMProjects.Project {projectId = IDs.toText projectId, projectName, latestRelease = Nothing, defaultBranch = Nothing}
pure $ UCMProjects.CreateProjectResponseSuccess apiProject

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"body": {
"subscriptions": []
},
"status": [
{
"status_code": 200
}
]
}
22 changes: 22 additions & 0 deletions transcripts/share-apis/notifications/list-subscriptions-test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"body": {
"subscriptions": [
{
"filter": {
"projectId": "P-<UUID>"
},
"id": "NS-<UUID>",
"scope": "U-<UUID>",
"topicGroups": [
"watch_project"
],
"topics": []
}
]
},
"status": [
{
"status_code": 200
}
]
}
27 changes: 15 additions & 12 deletions transcripts/share-apis/notifications/run.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,10 @@ source "../../transcript_helpers.sh"

publictestproject_id=$(project_id_from_handle_and_slug 'test' 'publictestproject')

# Subscribe to project-related notifications for the test user's publictestproject.
# This subscription uses a topic group rather than a specific topic.
subscription_id=$(fetch_data_jq "$test_user" POST create-subscription-for-project '/users/test/notifications/subscriptions' '.subscription.id' "{
\"scope\": \"test\",
\"topics\": [],
\"topicGroups\": [
\"watch_project\"
],
\"filter\": {
\"projectId\": \"$publictestproject_id\"
}
}" )
# Can subscribe to project-related notifications for the test user's publictestproject.
subscription_id=$(fetch_data_jq "$test_user" PUT subscribe-to-watch-project '/users/test/projects/publictestproject/subscription' '.subscriptionId' '{
"isSubscribed": true
}' )

webhook_id=$(fetch_data_jq "$test_user" POST create-webhook '/users/test/notifications/delivery-methods/webhooks' '.webhookId' "{
\"url\": \"${echo_server}/good-webhook\",
Expand Down Expand Up @@ -137,3 +129,14 @@ successful_webhooks=$(pg_sql "SELECT COUNT(*) FROM notification_webhook_queue WH
unsuccessful_webhooks=$(pg_sql "SELECT COUNT(*) FROM notification_webhook_queue WHERE NOT delivered;")

echo "Successful webhooks: $successful_webhooks\nUnsuccessful webhooks: $unsuccessful_webhooks\n" > ./webhook_results.txt

# List 'test' user's subscriptions
fetch "$test_user" GET list-subscriptions-test '/users/test/notifications/subscriptions'

# Can unsubscribe from project-related notifications for the test user's publictestproject.
fetch "$test_user" PUT unsubscribe-from-project '/users/test/projects/publictestproject/subscription' '{
"isSubscribed": false
}'

# List 'test' user's subscriptions again
fetch "$test_user" GET list-subscriptions-test-after-unsubscribe '/users/test/notifications/subscriptions'
10 changes: 10 additions & 0 deletions transcripts/share-apis/notifications/unsubscribe-from-project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"body": {
"subscriptionId": null
},
"status": [
{
"status_code": 200
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"defaultBranch": "main",
"isFaved": false,
"isPremiumProject": true,
"isSubscribed": false,
"latestRelease": null,
"numActiveContributions": 0,
"numClosedContributions": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"defaultBranch": null,
"isFaved": true,
"isPremiumProject": true,
"isSubscribed": true,
"latestRelease": null,
"numActiveContributions": 0,
"numClosedContributions": 0,
Expand Down
Loading
Loading