Skip to content

Commit 5222886

Browse files
authored
Merge pull request #91 from unisoncomputing/cp/project-watch-endpoints
Add endpoints for subscribing to a project
2 parents f8c8916 + 41be935 commit 5222886

20 files changed

+206
-26
lines changed

sql/2025-06-04_watch-projects.sql

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Migration to add watch_project subscriptions for all existing users and projects.
2+
3+
INSERT INTO notification_subscriptions (subscriber_user_id, scope_user_id, topics, topic_groups, filter)
4+
SELECT u.id,
5+
p.owner_user_id,
6+
'{}',
7+
ARRAY['watch_project'::notification_topic_group]::notification_topic_group[],
8+
jsonb_build_object('projectId', p.id)
9+
FROM users u
10+
JOIN projects p ON u.id = p.owner_user_id
11+
WHERE NOT EXISTS (
12+
SELECT FROM notification_subscriptions ns
13+
WHERE ns.subscriber_user_id = u.id
14+
AND ns.scope_user_id = p.owner_user_id
15+
AND ns.topics = '{}'
16+
AND ns.topic_groups = ARRAY['watch_project'::notification_topic_group]
17+
AND ns.filter = jsonb_build_object('projectId', p.id)
18+
)
19+
;

src/Share/Notifications/Queries.hs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ module Share.Notifications.Queries
1919
getNotificationSubscription,
2020
hydrateEventPayload,
2121
hasUnreadNotifications,
22+
updateWatchProjectSubscription,
23+
isUserSubscribedToWatchProject,
2224
)
2325
where
2426

2527
import Control.Lens
28+
import Data.Aeson qualified as Aeson
2629
import Data.Foldable qualified as Foldable
2730
import Data.Ord (clamp)
31+
import Data.Set qualified as Set
2832
import Data.Set.NonEmpty (NESet)
2933
import Share.Contribution
3034
import Share.IDs
@@ -464,3 +468,45 @@ hydrateEventPayload = \case
464468
|]
465469
)
466470
<*> (UsersQ.userDisplayInfoOf id commentAuthorUserId)
471+
472+
-- | Subscribe or unsubscribe to watching a project
473+
updateWatchProjectSubscription :: UserId -> ProjectId -> Bool -> Transaction e (Maybe NotificationSubscriptionId)
474+
updateWatchProjectSubscription userId projId shouldBeSubscribed = do
475+
let filter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId]
476+
existing <- isUserSubscribedToWatchProject userId projId
477+
case existing of
478+
Just existingId
479+
| not shouldBeSubscribed -> do
480+
execute_
481+
[sql|
482+
DELETE FROM notification_subscriptions
483+
WHERE id = #{existingId}
484+
AND subscriber_user_id = #{userId}
485+
|]
486+
pure Nothing
487+
| otherwise -> pure (Just existingId)
488+
Nothing | shouldBeSubscribed -> do
489+
-- Create a new subscription
490+
projectOwnerUserId <-
491+
queryExpect1Col
492+
[sql|
493+
SELECT p.owner_user_id
494+
FROM projects p
495+
WHERE p.id = #{projId}
496+
|]
497+
Just <$> createNotificationSubscription userId projectOwnerUserId mempty (Set.singleton WatchProject) (Just filter)
498+
_ -> pure Nothing
499+
500+
isUserSubscribedToWatchProject :: UserId -> ProjectId -> Transaction e (Maybe NotificationSubscriptionId)
501+
isUserSubscribedToWatchProject userId projId = do
502+
let filter = SubscriptionFilter $ Aeson.object ["projectId" Aeson..= projId]
503+
query1Col @NotificationSubscriptionId
504+
[sql|
505+
SELECT ns.id FROM notification_subscriptions ns
506+
JOIN projects p ON p.id = #{projId}
507+
WHERE ns.subscriber_user_id = #{userId}
508+
AND ns.scope_user_id = p.owner_user_id
509+
AND ns.topic_groups = ARRAY[#{WatchProject}::notification_topic_group]
510+
AND ns.filter = #{filter}::jsonb
511+
LIMIT 1
512+
|]

src/Share/Postgres/Ops.hs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Control.Monad.Except
55
import Servant
66
import Share.IDs (ProjectId, ProjectSlug (..), UserHandle (..), UserId (..))
77
import Share.IDs qualified as IDs
8+
import Share.Notifications.Queries qualified as Notifs
89
import Share.Postgres qualified as PG
910
import Share.Postgres.Queries as Q
1011
import Share.Postgres.Users.Queries qualified as UserQ
@@ -46,9 +47,12 @@ projectIdByUserHandleAndSlug :: UserHandle -> ProjectSlug -> WebApp ProjectId
4647
projectIdByUserHandleAndSlug userHandle projectSlug = do
4748
PG.runTransaction (Q.projectIDFromHandleAndSlug userHandle projectSlug) `or404` (EntityMissing (ErrorID "no-project-for-handle-and-slug") $ "Project not found: " <> IDs.toText userHandle <> "/" <> IDs.toText projectSlug)
4849

49-
createProject :: UserId -> ProjectSlug -> Maybe Text -> Set ProjectTag -> ProjectVisibility -> WebApp ProjectId
50-
createProject ownerUserId slug summary tags visibility = do
50+
createProject :: UserId -> UserId -> ProjectSlug -> Maybe Text -> Set ProjectTag -> ProjectVisibility -> WebApp ProjectId
51+
createProject caller ownerUserId slug summary tags visibility = do
5152
PG.runTransactionOrRespondError do
5253
Q.projectIDFromUserIdAndSlug ownerUserId slug >>= \case
5354
Just projectId -> throwError (ProjectAlreadyExists ownerUserId slug projectId)
54-
Nothing -> Q.createProject ownerUserId slug summary tags visibility
55+
Nothing -> do
56+
projId <- Q.createProject ownerUserId slug summary tags visibility
57+
_ <- Notifs.updateWatchProjectSubscription caller projId True
58+
pure projId

src/Share/Web/Authorization.hs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,8 @@ checkDownloadFromProjectBranchCodebase :: WebApp (Either AuthZFailure AuthZ.Auth
393393
checkDownloadFromProjectBranchCodebase =
394394
pure . Right $ AuthZ.UnsafeAuthZReceipt Nothing
395395

396-
checkProjectCreate :: Maybe UserId -> UserId -> WebApp (Either AuthZFailure AuthZ.AuthZReceipt)
397-
checkProjectCreate mayReqUserId targetUserId = maybePermissionFailure (ProjectPermission (ProjectCreate targetUserId)) $ do
398-
reqUserId <- guardMaybe mayReqUserId
396+
checkProjectCreate :: UserId -> UserId -> WebApp (Either AuthZFailure AuthZ.AuthZReceipt)
397+
checkProjectCreate reqUserId targetUserId = maybePermissionFailure (ProjectPermission (ProjectCreate targetUserId)) $ do
399398
-- Can create projects in their own user, or in any org they have permission to create projects in.
400399
guard (reqUserId == targetUserId) <|> checkCreateInOrg reqUserId
401400
pure $ AuthZ.UnsafeAuthZReceipt Nothing

src/Share/Web/Authorization/Types.hs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ module Share.Web.Authorization.Types
2626
RemoveRolesResponse (..),
2727
AddRolesRequest (..),
2828
RemoveRolesRequest (..),
29+
ProjectNotificationSubscriptionRequest (..),
30+
ProjectNotificationSubscriptionResponse (..),
2931

3032
-- * AuthZReceipt
3133
AuthZReceipt (..),
@@ -434,3 +436,26 @@ data AuthZReceipt = UnsafeAuthZReceipt {getCacheability :: Maybe CachingToken}
434436
-- | Requests should only be cached if they're for a public endpoint.
435437
-- Obtaining a caching token is proof that the resource was public and can be cached.
436438
data CachingToken = CachingToken
439+
440+
data ProjectNotificationSubscriptionRequest
441+
= ProjectNotificationSubscriptionRequest
442+
{ isSubscribed :: Bool
443+
}
444+
deriving (Show, Eq)
445+
446+
instance FromJSON ProjectNotificationSubscriptionRequest where
447+
parseJSON = Aeson.withObject "ProjectNotificationSubscriptionRequest" $ \o -> do
448+
isSubscribed <- o Aeson..: "isSubscribed"
449+
pure ProjectNotificationSubscriptionRequest {isSubscribed}
450+
451+
data ProjectNotificationSubscriptionResponse
452+
= ProjectNotificationSubscriptionResponse
453+
{ subscriptionId :: Maybe NotificationSubscriptionId
454+
}
455+
deriving (Show, Eq)
456+
457+
instance ToJSON ProjectNotificationSubscriptionResponse where
458+
toJSON ProjectNotificationSubscriptionResponse {..} =
459+
object
460+
[ "subscriptionId" .= subscriptionId
461+
]

src/Share/Web/Share/Projects/API.hs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ type ProjectResourceAPI =
4141
:<|> GetProjectEndpoint
4242
:<|> ( "fav" :> FavProjectEndpoint
4343
)
44-
:<|> "roles" :> MaintainersResourceAPI
44+
:<|> ("roles" :> MaintainersResourceAPI)
45+
:<|> ("subscription" :> ProjectNotificationSubscriptionEndpoint)
4546
)
4647

4748
type ProjectDiffNamespacesEndpoint =
@@ -111,3 +112,7 @@ type RemoveRolesEndpoint =
111112
:>
112113
-- Return the updated list of maintainers
113114
Delete '[JSON] RemoveRolesResponse
115+
116+
type ProjectNotificationSubscriptionEndpoint =
117+
ReqBody '[JSON] ProjectNotificationSubscriptionRequest
118+
:> Put '[JSON] ProjectNotificationSubscriptionResponse

src/Share/Web/Share/Projects/Impl.hs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Share.Codebase qualified as Codebase
2020
import Share.Env qualified as Env
2121
import Share.IDs (PrefixedHash (..), ProjectSlug (..), UserHandle, UserId)
2222
import Share.IDs qualified as IDs
23+
import Share.Notifications.Queries qualified as NotifsQ
2324
import Share.OAuth.Session
2425
import Share.Postgres qualified as PG
2526
import Share.Postgres.Authorization.Queries qualified as AuthZQ
@@ -118,6 +119,7 @@ projectServer session handle =
118119
:<|> getProjectEndpoint session handle slug
119120
:<|> favProjectEndpoint session handle slug
120121
:<|> maintainersResourceServer slug
122+
:<|> projectNotificationSubscriptionEndpoint session handle slug
121123
)
122124
where
123125
addTags :: forall x. ProjectSlug -> WebApp x -> WebApp x
@@ -308,11 +310,12 @@ namespaceHashForBranchOrRelease authZReceipt Project {projectId, ownerUserId = p
308310
pure (codebase, causalId, branchHashId)
309311

310312
createProjectEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> CreateProjectRequest -> WebApp CreateProjectResponse
311-
createProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle projectSlug req = do
313+
createProjectEndpoint mayCaller userHandle projectSlug req = do
314+
callerUserId <- AuthN.requireAuthenticatedUser mayCaller
312315
User {user_id = targetUserId} <- PGO.expectUserByHandle userHandle
313316
AuthZ.permissionGuard $ AuthZ.checkProjectCreate callerUserId targetUserId
314317
let CreateProjectRequest {summary, tags, visibility} = req
315-
projectId <- PGO.createProject targetUserId projectSlug summary tags visibility
318+
projectId <- PGO.createProject callerUserId targetUserId projectSlug summary tags visibility
316319
addRequestTag "project-id" (IDs.toText projectId)
317320
pure CreateProjectResponse
318321

@@ -344,17 +347,23 @@ getProjectEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> WebApp GetPr
344347
getProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle projectSlug = do
345348
projectId <- PG.runTransaction (Q.projectIDFromHandleAndSlug userHandle projectSlug) `or404` (EntityMissing (ErrorID "project:missing") "Project not found")
346349
addRequestTag "project-id" (IDs.toText projectId)
347-
(releaseDownloads, (project, favData, projectOwner, defaultBranch, latestRelease), contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo) <- PG.runTransactionOrRespondError do
350+
(releaseDownloads, (project, favData, projectOwner, defaultBranch, latestRelease), contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo, isUserWatchingProject) <- PG.runTransactionOrRespondError do
348351
projectWithMeta <- (Q.projectByIdWithMetadata callerUserId projectId) `whenNothingM` throwError (EntityMissing (ErrorID "project:missing") "Project not found")
349352
releaseDownloads <- Q.releaseDownloadStatsForProject projectId
350353
contributionStats <- Q.contributionStatsForProject projectId
351354
ticketStats <- Q.ticketStatsForProject projectId
352355
permissionsForProject <- PermissionsInfo <$> AuthZQ.permissionsForProject callerUserId projectId
353356
isPremiumProjectInfo <- IsPremiumProject <$> ProjectsQ.isPremiumProject projectId
354-
pure (releaseDownloads, projectWithMeta, contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo)
357+
isUserWatchingProject <- case callerUserId of
358+
Nothing -> pure $ IsSubscribed False
359+
Just caller -> do
360+
NotifsQ.isUserSubscribedToWatchProject caller projectId >>= \case
361+
Just _ -> pure $ IsSubscribed True
362+
Nothing -> pure $ IsSubscribed False
363+
pure (releaseDownloads, projectWithMeta, contributionStats, ticketStats, permissionsForProject, isPremiumProjectInfo, isUserWatchingProject)
355364
let releaseDownloadStats = ReleaseDownloadStats {releaseDownloads}
356365
AuthZ.permissionGuard $ AuthZ.checkProjectGet callerUserId projectId
357-
pure (projectToAPI projectOwner project :++ favData :++ APIProjectBranchAndReleaseDetails {defaultBranch, latestRelease} :++ releaseDownloadStats :++ contributionStats :++ ticketStats :++ permissionsForProject :++ isPremiumProjectInfo)
366+
pure (projectToAPI projectOwner project :++ favData :++ APIProjectBranchAndReleaseDetails {defaultBranch, latestRelease} :++ releaseDownloadStats :++ contributionStats :++ ticketStats :++ permissionsForProject :++ isPremiumProjectInfo :++ isUserWatchingProject)
358367

359368
favProjectEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> FavProjectRequest -> WebApp NoContent
360369
favProjectEndpoint sess userHandle projectSlug (FavProjectRequest {isFaved}) = do
@@ -406,3 +415,15 @@ removeRolesEndpoint session projectUserHandle projectSlug (RemoveRolesRequest {r
406415
updatedRoles <- ProjectsQ.removeProjectRoles projectId roleAssignments
407416
roleAssignments <- canonicalRoleAssignmentOrdering <$> displaySubjectsOf (traversed . traversed) updatedRoles
408417
pure $ RemoveRolesResponse {roleAssignments}
418+
419+
projectNotificationSubscriptionEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> ProjectNotificationSubscriptionRequest -> WebApp ProjectNotificationSubscriptionResponse
420+
projectNotificationSubscriptionEndpoint session projectUserHandler projectSlug (ProjectNotificationSubscriptionRequest {isSubscribed}) = do
421+
caller <- AuthN.requireAuthenticatedUser session
422+
projectId <- PG.runTransactionOrRespondError $ do
423+
Q.projectIDFromHandleAndSlug projectUserHandler projectSlug `whenNothingM` throwError (EntityMissing (ErrorID "project:missing") "Project not found")
424+
_authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkSubscriptionsManage caller caller
425+
maySubscriptionId <- PG.runTransaction $ NotifsQ.updateWatchProjectSubscription caller projectId isSubscribed
426+
pure $
427+
ProjectNotificationSubscriptionResponse
428+
{ subscriptionId = maySubscriptionId
429+
}

src/Share/Web/Share/Projects/Types.hs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module Share.Web.Share.Projects.Types
2525
IsPremiumProject (..),
2626
APIProjectBranchAndReleaseDetails (..),
2727
PermissionsInfo (..),
28+
IsSubscribed (..),
2829
)
2930
where
3031

@@ -223,6 +224,12 @@ newtype IsPremiumProject = IsPremiumProject
223224
deriving (Show)
224225
deriving (ToJSON) via (AtKey "isPremiumProject" Bool)
225226

227+
newtype IsSubscribed = IsSubscribed
228+
{ isSubscribed :: Bool
229+
}
230+
deriving (Show)
231+
deriving (ToJSON) via (AtKey "isSubscribed" Bool)
232+
226233
type GetProjectResponse =
227234
APIProject
228235
:++ FavData
@@ -232,6 +239,7 @@ type GetProjectResponse =
232239
:++ TicketStats
233240
:++ PermissionsInfo
234241
:++ IsPremiumProject
242+
:++ IsSubscribed
235243

236244
data ListProjectsResponse = ListProjectsResponse
237245
{ projects :: [APIProject :++ FavData]

src/Share/Web/UCM/Projects/Impl.hs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ getProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) mayUcmProjectId mayUcm
8787
lift $ parseParam @ProjectId "id" ucmProjectId
8888

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"body": {
3+
"subscriptions": []
4+
},
5+
"status": [
6+
{
7+
"status_code": 200
8+
}
9+
]
10+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"body": {
3+
"subscriptions": [
4+
{
5+
"filter": {
6+
"projectId": "P-<UUID>"
7+
},
8+
"id": "NS-<UUID>",
9+
"scope": "U-<UUID>",
10+
"topicGroups": [
11+
"watch_project"
12+
],
13+
"topics": []
14+
}
15+
]
16+
},
17+
"status": [
18+
{
19+
"status_code": 200
20+
}
21+
]
22+
}

transcripts/share-apis/notifications/run.zsh

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,10 @@ source "../../transcript_helpers.sh"
66

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

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

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

139131
echo "Successful webhooks: $successful_webhooks\nUnsuccessful webhooks: $unsuccessful_webhooks\n" > ./webhook_results.txt
132+
133+
# List 'test' user's subscriptions
134+
fetch "$test_user" GET list-subscriptions-test '/users/test/notifications/subscriptions'
135+
136+
# Can unsubscribe from project-related notifications for the test user's publictestproject.
137+
fetch "$test_user" PUT unsubscribe-from-project '/users/test/projects/publictestproject/subscription' '{
138+
"isSubscribed": false
139+
}'
140+
141+
# List 'test' user's subscriptions again
142+
fetch "$test_user" GET list-subscriptions-test-after-unsubscribe '/users/test/notifications/subscriptions'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"body": {
3+
"subscriptionId": null
4+
},
5+
"status": [
6+
{
7+
"status_code": 200
8+
}
9+
]
10+
}

transcripts/share-apis/project-maintainers/read-maintainer-project-view.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"defaultBranch": "main",
55
"isFaved": false,
66
"isPremiumProject": true,
7+
"isSubscribed": false,
78
"latestRelease": null,
89
"numActiveContributions": 0,
910
"numClosedContributions": 0,

transcripts/share-apis/projects-flow/project-get-after-update.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"defaultBranch": null,
55
"isFaved": true,
66
"isPremiumProject": true,
7+
"isSubscribed": true,
78
"latestRelease": null,
89
"numActiveContributions": 0,
910
"numClosedContributions": 0,

0 commit comments

Comments
 (0)