From aa9a34e268f12f9b9575009fba2b79388f06cdb7 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Thu, 22 May 2025 12:35:03 -0700 Subject: [PATCH 1/6] Add prevCursor to paging info --- src/Share/Utils/API.hs | 50 +++++++++++++++++++--------- src/Share/Web/Share/Branches/Impl.hs | 4 +-- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/Share/Utils/API.hs b/src/Share/Utils/API.hs index 254def53..ab4206f6 100644 --- a/src/Share/Utils/API.hs +++ b/src/Share/Utils/API.hs @@ -159,33 +159,51 @@ applySetUpdate existing = \case in Foldable.foldl' go existing (Map.toList updates) SetReplacement new -> new +data CursorDirection = Before | After + deriving (Eq, Ord, Show) + -- | A cursor for a pageable endpoint. -- This is rendered to an opaque base64URL string and included in paged responses, -- if provided back to the endpoint it will be used to determine the starting -- point of the next page. -newtype Cursor a = Cursor {unCursor :: a} +data Cursor a = Cursor {location :: a, direction :: CursorDirection} deriving stock (Eq, Ord, Show) -- | --- >>> toUrlPiece (Cursor (1 :: Int, "hello" :: String)) --- "WzEsImhlbGxvIl0" +-- >>> toUrlPiece (Cursor (1 :: Int, "hello" :: String) After) +-- "a.WzEsImhlbGxvIl0" -- --- >>> parseUrlPiece (toUrlPiece (Cursor (1 :: Int, "hello" :: String))) :: Either Text (Cursor (Int, String)) --- Right (Cursor (1,"hello")) +-- >>> parseUrlPiece (toUrlPiece (Cursor (1 :: Int, "hello" :: String) After)) :: Either Text (Cursor (Int, String)) +-- Right (Cursor {location = (1,"hello"), direction = After}) instance (ToJSON a) => ToHttpApiData (Cursor a) where - toUrlPiece (Cursor a) = toUrlPiece . TL.decodeUtf8 . Base64URL.encodeUnpadded . Aeson.encode $ a + toUrlPiece (Cursor {location, direction}) = + let loc = TL.decodeUtf8 . Base64URL.encodeUnpadded . Aeson.encode $ location + dir :: TL.Text + dir = case direction of + Before -> "b" + After -> "a" + in toUrlPiece $ dir <> "." <> loc instance (FromJSON a) => FromHttpApiData (Cursor a) where parseUrlPiece txt = do - jsonBytes <- mapLeft Text.pack . Base64URL.decodeUnpadded . TL.encodeUtf8 . TL.fromStrict $ txt - Cursor <$> mapLeft Text.pack (Aeson.eitherDecode jsonBytes) + let (dirTxt, locTxt) = second (Text.drop 1) $ Text.breakOn "." txt + dir <- case dirTxt of + "b" -> pure Before + "a" -> pure After + _ -> Left $ "Invalid or missing cursor direction: " <> dirTxt + jsonBytes <- mapLeft Text.pack . Base64URL.decodeUnpadded . TL.encodeUtf8 . TL.fromStrict $ locTxt + loc <- mapLeft Text.pack (Aeson.eitherDecode jsonBytes) + pure $ Cursor loc dir -- | --- >>> toJSON (Cursor (1 :: Int, "hello" :: String)) --- String "WzEsImhlbGxvIl0" +-- >>> toJSON (Cursor (1 :: Int, "hello" :: String) After) +-- String "a.WzEsImhlbGxvIl0" +-- +-- >>> toJSON (Cursor (1 :: Int, "hello" :: String) Before) +-- String "b.WzEsImhlbGxvIl0" -- --- >>> decode (encode (Cursor (1 :: Int, "hello" :: String))) :: Maybe (Cursor (Int, String)) --- Just (Cursor (1,"hello")) +-- >>> decode (encode (Cursor (1 :: Int, "hello" :: String) After)) :: Maybe (Cursor (Int, String)) +-- Just (Cursor {location = (1,"hello"), direction = After}) instance (ToJSON a) => ToJSON (Cursor a) where toJSON = toJSON . toUrlPiece @@ -195,15 +213,17 @@ instance (FromJSON a) => FromJSON (Cursor a) where data Paged cursor a = Paged { items :: [a], - cursor :: Maybe (Cursor cursor) + prevCursor :: Maybe (Cursor cursor), + nextCursor :: Maybe (Cursor cursor) } deriving stock (Eq, Show) instance (ToJSON a, ToJSON cursor) => ToJSON (Paged cursor a) where - toJSON (Paged items cursor) = + toJSON (Paged {items, prevCursor, nextCursor}) = object [ "items" .= items, - "cursor" .= cursor + "prevCursor" .= prevCursor, + "nextCursor" .= nextCursor ] -- | The maximum page size for pageable endpoints diff --git a/src/Share/Web/Share/Branches/Impl.hs b/src/Share/Web/Share/Branches/Impl.hs index bbd6f336..23d91035 100644 --- a/src/Share/Web/Share/Branches/Impl.hs +++ b/src/Share/Web/Share/Branches/Impl.hs @@ -415,7 +415,7 @@ listBranchesByProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle let branchShortHand = BranchShortHand {branchName, contributorHandle} in API.branchToShareBranch branchShortHand branch shareProject contributions ) - pure $ Paged {items = shareBranches, cursor = nextCursor (toListOf (folded . _1) branches)} + pure $ Paged {items = shareBranches, nextCursor = nextCursor (toListOf (folded . _1) branches), prevCursor = mayCursor} where nextCursor branches = case branches of [] -> Nothing @@ -499,7 +499,7 @@ listBranchesByUserEndpoint (AuthN.MaybeAuthedUserID callerUserId) contributorHan shareProject = projectToAPI projectOwnerHandle project in API.branchToShareBranch branchShortHand branch shareProject contributions ) - pure $ Paged {items = shareBranches, cursor = nextCursor branches} + pure $ Paged {items = shareBranches, nextCursor = nextCursor branches, prevCursor = mayCursor} where defaultLimit = Limit 20 limit = fromMaybe defaultLimit mayLimit From fad6abb6fa50842c6d7bd03292b8f5a7b6ccadf5 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Thu, 22 May 2025 13:54:16 -0700 Subject: [PATCH 2/6] Finish updating all paged endpoints --- src/Share/Postgres/Contributions/Queries.hs | 52 +++++++++++++-------- src/Share/Postgres/Queries.hs | 10 ++-- src/Share/Postgres/Tickets/Queries.hs | 48 +++++++++++-------- src/Share/Utils/API.hs | 45 +++++++++++------- src/Share/Web/Share/Branches/Impl.hs | 27 ++++------- src/Share/Web/Share/Contributions/Impl.hs | 21 +++++---- src/Share/Web/Share/Releases/Impl.hs | 20 ++++---- src/Share/Web/Share/Tickets/Impl.hs | 21 +++++---- 8 files changed, 137 insertions(+), 107 deletions(-) diff --git a/src/Share/Postgres/Contributions/Queries.hs b/src/Share/Postgres/Contributions/Queries.hs index 44a7b8b0..a66b4372 100644 --- a/src/Share/Postgres/Contributions/Queries.hs +++ b/src/Share/Postgres/Contributions/Queries.hs @@ -28,7 +28,7 @@ import Data.List qualified as List import Data.Map qualified as Map import Data.Set qualified as Set import Data.Time (UTCTime) -import Safe (lastMay) +import Safe (headMay, lastMay) import Share.Codebase.Types (CodebaseEnv (..)) import Share.Contribution (Contribution (..), ContributionStatus (..)) import Share.IDs @@ -112,7 +112,7 @@ listContributionsByProjectId :: Maybe UserId -> Maybe ContributionStatus -> Maybe ContributionKindFilter -> - PG.Transaction e (Maybe (Cursor ListContributionsCursor), [ShareContribution UserId]) + PG.Transaction e (Paged ListContributionsCursor (ShareContribution UserId)) listContributionsByProjectId projectId limit mayCursor mayUserFilter mayStatusFilter mayKindFilter = do let kindFilter = case mayKindFilter of Nothing -> "true" @@ -122,11 +122,15 @@ listContributionsByProjectId projectId limit mayCursor mayUserFilter mayStatusFi OnlyContributorContributions -> [PG.sql| source_branch.contributor_id IS NOT NULL |] let cursorFilter = case mayCursor of Nothing -> "true" - Just (Cursor (beforeTime, contributionId)) -> + Just (Cursor (beforeTime, contributionId) Next) -> [PG.sql| (contribution.updated_at, contribution.id) < (#{beforeTime}, #{contributionId}) |] - addCursor + Just (Cursor (afterTime, contributionId) Previous) -> + [PG.sql| + (contribution.updated_at, contribution.id) > (#{afterTime}, #{contributionId}) + |] + paged <$> PG.queryListRows @(ShareContribution UserId) [PG.sql| SELECT @@ -162,12 +166,15 @@ listContributionsByProjectId projectId limit mayCursor mayUserFilter mayStatusFi LIMIT #{limit} |] where - addCursor :: [ShareContribution UserId] -> (Maybe (Cursor ListContributionsCursor), [ShareContribution UserId]) - addCursor xs = - ( lastMay xs <&> \(ShareContribution {updatedAt, contributionId}) -> - Cursor (updatedAt, contributionId), - xs - ) + paged :: [ShareContribution UserId] -> Paged ListContributionsCursor (ShareContribution UserId) + paged items = + let prevCursor = headMay items <&> \(ShareContribution {updatedAt, contributionId}) -> Cursor (updatedAt, contributionId) Previous + nextCursor = lastMay items <&> \(ShareContribution {updatedAt, contributionId}) -> Cursor (updatedAt, contributionId) Next + in Paged + { items, + prevCursor, + nextCursor + } contributionById :: (PG.QueryA m) => ContributionId -> m Contribution contributionById contributionId = do @@ -268,7 +275,7 @@ listContributionsByUserId :: Maybe (Cursor (UTCTime, ContributionId)) -> Maybe ContributionStatus -> Maybe ContributionKindFilter -> - PG.Transaction e (Maybe (Cursor (UTCTime, ContributionId)), [ShareContribution UserId]) + PG.Transaction e (Paged (UTCTime, ContributionId) (ShareContribution UserId)) listContributionsByUserId callerUserId userId limit mayCursor mayStatusFilter mayKindFilter = do let kindFilter = case mayKindFilter of Nothing -> "true" @@ -278,11 +285,15 @@ listContributionsByUserId callerUserId userId limit mayCursor mayStatusFilter ma OnlyContributorContributions -> [PG.sql| source_branch.contributor_id IS NOT NULL |] let cursorFilter = case mayCursor of Nothing -> "true" - Just (Cursor (beforeTime, contributionId)) -> + Just (Cursor (beforeTime, contributionId) Next) -> [PG.sql| (contribution.updated_at, contribution.id) < (#{beforeTime}, #{contributionId}) |] - addCursor + Just (Cursor (afterTime, contributionId) Previous) -> + [PG.sql| + (contribution.updated_at, contribution.id) > (#{afterTime}, #{contributionId}) + |] + paged <$> PG.queryListRows @(ShareContribution UserId) [PG.sql| SELECT @@ -313,12 +324,15 @@ listContributionsByUserId callerUserId userId limit mayCursor mayStatusFilter ma LIMIT #{limit} |] where - addCursor :: [ShareContribution UserId] -> (Maybe (Cursor ListContributionsCursor), [ShareContribution UserId]) - addCursor xs = - ( lastMay xs <&> \(ShareContribution {updatedAt, contributionId}) -> - Cursor (updatedAt, contributionId), - xs - ) + paged :: [ShareContribution UserId] -> (Paged ListContributionsCursor (ShareContribution UserId)) + paged items = + let prevCursor = headMay items <&> \(ShareContribution {updatedAt, contributionId}) -> Cursor (updatedAt, contributionId) Previous + nextCursor = lastMay items <&> \(ShareContribution {updatedAt, contributionId}) -> Cursor (updatedAt, contributionId) Next + in Paged + { items, + prevCursor, + nextCursor + } -- | Note: Doesn't perform auth checks, the assumption is that if you already have access to -- the branchId you have access to all associated contributions. diff --git a/src/Share/Postgres/Queries.hs b/src/Share/Postgres/Queries.hs index fad6736f..606bb888 100644 --- a/src/Share/Postgres/Queries.hs +++ b/src/Share/Postgres/Queries.hs @@ -772,7 +772,8 @@ listBranchesByProject limit mayCursor mayBranchNamePrefix mayContributorQuery ki Just (Query branchNamePrefix) -> [PG.sql| AND starts_with(b.name, #{branchNamePrefix}) |] let cursorFilter = case mayCursor of Nothing -> mempty - Just (Cursor (beforeTime, branchId)) -> [PG.sql| AND (b.updated_at, b.id) < (#{beforeTime}, #{branchId})|] + Just (Cursor (beforeTime, branchId) Previous) -> [PG.sql| AND (b.updated_at, b.id) < (#{beforeTime}, #{branchId})|] + Just (Cursor (afterTime, branchId) Next) -> [PG.sql| AND (b.updated_at, b.id) > (#{afterTime}, #{branchId})|] let sql = intercalateMap "\n" @@ -873,7 +874,8 @@ listContributorBranchesOfUserAccessibleToCaller contributorUserId mayCallerUserI Just (Query branchNamePrefix) -> [PG.sql| AND starts_with(b.name, #{branchNamePrefix}) |] let cursorFilter = case mayCursor of Nothing -> mempty - Just (Cursor (beforeTime, branchId)) -> [PG.sql| AND (b.updated_at, b.id) < (#{beforeTime}, #{branchId}) |] + Just (Cursor (beforeTime, branchId) Previous) -> [PG.sql| AND (b.updated_at, b.id) < (#{beforeTime}, #{branchId}) |] + Just (Cursor (afterTime, branchId) Next) -> [PG.sql| AND (b.updated_at, b.id) > (#{afterTime}, #{branchId}) |] let projectFilter = case mayProjectId of Nothing -> mempty Just projId -> [PG.sql| AND b.project_id = #{projId} |] @@ -1128,8 +1130,10 @@ listReleasesByProject limit mayCursor mayVersionPrefix status projectId = do in numericalFilters let cursorFilter = case mayCursor of Nothing -> mempty - Just (Cursor (major, minor, patch, releaseId)) -> + Just (Cursor (major, minor, patch, releaseId) Previous) -> [PG.sql| AND (release.major_version, release.minor_version, release.patch_version, release.id) < (#{major}, #{minor}, #{patch}, #{releaseId}) |] + Just (Cursor (major, minor, patch, releaseId) Next) -> + [PG.sql| AND (release.major_version, release.minor_version, release.patch_version, release.id) >= (#{major}, #{minor}, #{patch}, #{releaseId}) |] let (sql) = intercalateMap "\n" diff --git a/src/Share/Postgres/Tickets/Queries.hs b/src/Share/Postgres/Tickets/Queries.hs index 08465db5..f0799f94 100644 --- a/src/Share/Postgres/Tickets/Queries.hs +++ b/src/Share/Postgres/Tickets/Queries.hs @@ -20,7 +20,7 @@ where import Control.Lens import Data.List qualified as List import Data.Time (UTCTime) -import Safe (lastMay) +import Safe (headMay, lastMay) import Share.IDs import Share.Postgres qualified as PG import Share.Prelude @@ -122,15 +122,19 @@ listTicketsByProjectId :: Maybe (Cursor ListTicketsCursor) -> Maybe UserId -> Maybe TicketStatus -> - PG.Transaction e (Maybe (Cursor ListTicketsCursor), [(ShareTicket UserId)]) + PG.Transaction e (Paged ListTicketsCursor (ShareTicket UserId)) listTicketsByProjectId projectId limit mayCursor mayUserFilter mayStatusFilter = do let cursorFilter = case mayCursor of Nothing -> "true" - Just (Cursor (beforeTime, ticketId)) -> + Just (Cursor (beforeTime, ticketId) Next) -> [PG.sql| (ticket.updated_at, ticket.id) < (#{beforeTime}, #{ticketId}) |] - addCursor + Just (Cursor (afterTime, ticketId) Previous) -> + [PG.sql| + (ticket.updated_at, ticket.id) > (#{afterTime}, #{ticketId}) + |] + paged <$> PG.queryListRows @(ShareTicket UserId) [PG.sql| SELECT @@ -157,12 +161,11 @@ listTicketsByProjectId projectId limit mayCursor mayUserFilter mayStatusFilter = LIMIT #{limit} |] where - addCursor :: [ShareTicket UserId] -> (Maybe (Cursor ListTicketsCursor), [ShareTicket UserId]) - addCursor xs = - ( lastMay xs <&> \(ShareTicket {updatedAt, ticketId}) -> - Cursor (updatedAt, ticketId), - xs - ) + paged :: [ShareTicket UserId] -> (Paged ListTicketsCursor (ShareTicket UserId)) + paged items = + let prevCursor = headMay items <&> \ShareTicket {updatedAt, ticketId} -> Cursor (updatedAt, ticketId) Previous + nextCursor = lastMay items <&> \(ShareTicket {updatedAt, ticketId}) -> Cursor (updatedAt, ticketId) Next + in Paged {items, prevCursor, nextCursor} ticketById :: TicketId -> PG.Transaction e (Maybe Ticket) ticketById ticketId = do @@ -287,15 +290,19 @@ listTicketsByUserId :: Limit -> Maybe (Cursor (UTCTime, TicketId)) -> Maybe TicketStatus -> - PG.Transaction e (Maybe (Cursor (UTCTime, TicketId)), [ShareTicket UserId]) + PG.Transaction e (Paged (UTCTime, TicketId) (ShareTicket UserId)) listTicketsByUserId callerUserId userId limit mayCursor mayStatusFilter = do let cursorFilter = case mayCursor of Nothing -> "true" - Just (Cursor (beforeTime, ticketId)) -> + Just (Cursor (beforeTime, ticketId) Next) -> [PG.sql| (ticket.updated_at, ticket.id) < (#{beforeTime}, #{ticketId}) |] - addCursor + Just (Cursor (afterTime, ticketId) Previous) -> + [PG.sql| + (ticket.updated_at, ticket.id) > (#{afterTime}, #{ticketId}) + |] + paged <$> PG.queryListRows @(ShareTicket UserId) [PG.sql| SELECT @@ -321,12 +328,15 @@ listTicketsByUserId callerUserId userId limit mayCursor mayStatusFilter = do LIMIT #{limit} |] where - addCursor :: [ShareTicket UserId] -> (Maybe (Cursor ListTicketsCursor), [ShareTicket UserId]) - addCursor xs = - ( lastMay xs <&> \(ShareTicket {updatedAt, ticketId}) -> - Cursor (updatedAt, ticketId), - xs - ) + paged :: [ShareTicket UserId] -> (Paged ListTicketsCursor (ShareTicket UserId)) + paged items = + let prevCursor = headMay items <&> \ShareTicket {updatedAt, ticketId} -> Cursor (updatedAt, ticketId) Previous + nextCursor = lastMay items <&> \(ShareTicket {updatedAt, ticketId}) -> Cursor (updatedAt, ticketId) Next + in Paged + { items, + prevCursor, + nextCursor + } getPagedShareTicketTimelineByProjectIdAndNumber :: ProjectId -> diff --git a/src/Share/Utils/API.hs b/src/Share/Utils/API.hs index ab4206f6..1b434749 100644 --- a/src/Share/Utils/API.hs +++ b/src/Share/Utils/API.hs @@ -17,6 +17,8 @@ module Share.Utils.API Limit (..), (:++) (..), AtKey (..), + CursorDirection (..), + pagedOn, ) where @@ -35,6 +37,7 @@ import Data.Text.Lazy.Encoding qualified as TL import Data.Typeable (typeRep) import GHC.TypeLits (Symbol) import GHC.TypeLits qualified as TypeLits +import Safe (headMay, lastMay) import Servant import Share.Postgres qualified as PG import Share.Prelude @@ -159,7 +162,7 @@ applySetUpdate existing = \case in Foldable.foldl' go existing (Map.toList updates) SetReplacement new -> new -data CursorDirection = Before | After +data CursorDirection = Previous | Next deriving (Eq, Ord, Show) -- | A cursor for a pageable endpoint. @@ -170,40 +173,40 @@ data Cursor a = Cursor {location :: a, direction :: CursorDirection} deriving stock (Eq, Ord, Show) -- | --- >>> toUrlPiece (Cursor (1 :: Int, "hello" :: String) After) --- "a.WzEsImhlbGxvIl0" +-- >>> toUrlPiece (Cursor (1 :: Int, "hello" :: String) Next) +-- "n.WzEsImhlbGxvIl0" -- --- >>> parseUrlPiece (toUrlPiece (Cursor (1 :: Int, "hello" :: String) After)) :: Either Text (Cursor (Int, String)) --- Right (Cursor {location = (1,"hello"), direction = After}) +-- >>> parseUrlPiece (toUrlPiece (Cursor (1 :: Int, "hello" :: String) Next)) :: Either Text (Cursor (Int, String)) +-- Right (Cursor {location = (1,"hello"), direction = Next}) instance (ToJSON a) => ToHttpApiData (Cursor a) where toUrlPiece (Cursor {location, direction}) = let loc = TL.decodeUtf8 . Base64URL.encodeUnpadded . Aeson.encode $ location dir :: TL.Text dir = case direction of - Before -> "b" - After -> "a" + Previous -> "p" + Next -> "n" in toUrlPiece $ dir <> "." <> loc instance (FromJSON a) => FromHttpApiData (Cursor a) where parseUrlPiece txt = do let (dirTxt, locTxt) = second (Text.drop 1) $ Text.breakOn "." txt dir <- case dirTxt of - "b" -> pure Before - "a" -> pure After + "p" -> pure Previous + "n" -> pure Next _ -> Left $ "Invalid or missing cursor direction: " <> dirTxt jsonBytes <- mapLeft Text.pack . Base64URL.decodeUnpadded . TL.encodeUtf8 . TL.fromStrict $ locTxt loc <- mapLeft Text.pack (Aeson.eitherDecode jsonBytes) pure $ Cursor loc dir -- | --- >>> toJSON (Cursor (1 :: Int, "hello" :: String) After) --- String "a.WzEsImhlbGxvIl0" +-- >>> toJSON (Cursor (1 :: Int, "hello" :: String) Next) +-- String "n.WzEsImhlbGxvIl0" -- --- >>> toJSON (Cursor (1 :: Int, "hello" :: String) Before) --- String "b.WzEsImhlbGxvIl0" +-- >>> toJSON (Cursor (1 :: Int, "hello" :: String) Previous) +-- String "p.WzEsImhlbGxvIl0" -- --- >>> decode (encode (Cursor (1 :: Int, "hello" :: String) After)) :: Maybe (Cursor (Int, String)) --- Just (Cursor {location = (1,"hello"), direction = After}) +-- >>> decode (encode (Cursor (1 :: Int, "hello" :: String) Next)) :: Maybe (Cursor (Int, String)) +-- Just (Cursor {location = (1,"hello"), direction = Next}) instance (ToJSON a) => ToJSON (Cursor a) where toJSON = toJSON . toUrlPiece @@ -216,7 +219,7 @@ data Paged cursor a = Paged prevCursor :: Maybe (Cursor cursor), nextCursor :: Maybe (Cursor cursor) } - deriving stock (Eq, Show) + deriving stock (Eq, Show, Functor, Foldable, Traversable) instance (ToJSON a, ToJSON cursor) => ToJSON (Paged cursor a) where toJSON (Paged {items, prevCursor, nextCursor}) = @@ -295,3 +298,13 @@ instance (FromJSON a, TypeLits.KnownSymbol key) => FromJSON (AtKey key a) where parseJSON = withObject "AtKey" $ \obj -> do (innerVal :: a) <- (obj .: String.fromString (TypeLits.symbolVal (Proxy @key))) pure $ AtKey innerVal + +pagedOn :: (a -> cursor) -> [a] -> Paged cursor a +pagedOn f items = + let prevCursor = do + first <- headMay items + pure $ Cursor (f first) Previous + nextCursor = do + last <- lastMay items + pure $ Cursor (f last) Next + in Paged items prevCursor nextCursor diff --git a/src/Share/Web/Share/Branches/Impl.hs b/src/Share/Web/Share/Branches/Impl.hs index 23d91035..98797c4d 100644 --- a/src/Share/Web/Share/Branches/Impl.hs +++ b/src/Share/Web/Share/Branches/Impl.hs @@ -7,7 +7,6 @@ module Share.Web.Share.Branches.Impl where import Control.Lens import Control.Monad.Trans.Maybe -import Data.List.NonEmpty qualified as NonEmpty import Data.Set qualified as Set import Data.Text qualified as Text import Data.Time (UTCTime) @@ -415,16 +414,11 @@ listBranchesByProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle let branchShortHand = BranchShortHand {branchName, contributorHandle} in API.branchToShareBranch branchShortHand branch shareProject contributions ) - pure $ Paged {items = shareBranches, nextCursor = nextCursor (toListOf (folded . _1) branches), prevCursor = mayCursor} + branches + & pagedOn (\(Branch {updatedAt, branchId}, _, _) -> (updatedAt, branchId)) + & (\p -> p {items = shareBranches}) + & pure where - nextCursor branches = case branches of - [] -> Nothing - branches@(x : xs) - | length branches < fromIntegral (getLimit limit) -> Nothing - | otherwise -> - let Branch {updatedAt, branchId} = NonEmpty.last (x :| xs) - in Just $ Cursor (updatedAt, branchId) - userIdForHandle handle = do fmap user_id <$> PG.runTransaction (UserQ.userByHandle handle) limit = fromMaybe defaultLimit mayLimit @@ -499,19 +493,14 @@ listBranchesByUserEndpoint (AuthN.MaybeAuthedUserID callerUserId) contributorHan shareProject = projectToAPI projectOwnerHandle project in API.branchToShareBranch branchShortHand branch shareProject contributions ) - pure $ Paged {items = shareBranches, nextCursor = nextCursor branches, prevCursor = mayCursor} + expandedBranches + & pagedOn ((\(Branch {updatedAt, branchId}, _contr, _proj, _projOwner) -> (updatedAt, branchId))) + & (\p -> p {items = shareBranches}) + & pure where defaultLimit = Limit 20 limit = fromMaybe defaultLimit mayLimit - nextCursor branches = case branches of - [] -> Nothing - branches@(x : xs) - | length branches < fromIntegral (getLimit limit) -> Nothing - | otherwise -> - let (Branch {updatedAt, branchId}, _proj, _projOwner) = NonEmpty.last (x :| xs) - in Just $ Cursor (updatedAt, branchId) - -- | Given an optional root hash, and a branch head, validate that the root hash is accessible from the branch head. resolveRootHash :: Codebase.CodebaseEnv -> CausalId -> Maybe CausalHash -> WebApp CausalId resolveRootHash codebase branchHead rootHash = do diff --git a/src/Share/Web/Share/Contributions/Impl.hs b/src/Share/Web/Share/Contributions/Impl.hs index a47f54de..fa4ee9f2 100644 --- a/src/Share/Web/Share/Contributions/Impl.hs +++ b/src/Share/Web/Share/Contributions/Impl.hs @@ -141,10 +141,10 @@ listContributionsByProjectEndpoint (AuthN.MaybeAuthedUserID mayCallerUserId) han User.user_id <$> UserQ.userByHandle authorHandle `whenNothingM` throwError (EntityMissing (ErrorID "user:missing") "User not found") pure (project, authorFilterID) _authReceipt <- AuthZ.permissionGuard $ AuthZ.checkContributionListByProject mayCallerUserId projectId - (nextCursor, contributions) <- PG.runTransaction $ do + pagedContributions <- PG.runTransaction $ do ContributionsQ.listContributionsByProjectId projectId limit cursor authorUserId statusFilter kindFilter - >>= UsersQ.userDisplayInfoOf (_2 . traversed . traversed) - pure $ Paged {items = contributions, cursor = nextCursor} + >>= UsersQ.userDisplayInfoOf (traversed . traversed) + pure $ pagedContributions where limit = fromMaybe 20 mayLimit projectShorthand = IDs.ProjectShortHand {userHandle = handle, projectSlug} @@ -227,13 +227,14 @@ getContributionTimelineEndpoint :: getContributionTimelineEndpoint (AuthN.MaybeAuthedUserID mayCallerUserId) userHandle projectSlug contributionNumber mayCursor mayLimit = do (Project {projectId}, shareContributionTimeline, nextCursor) <- PG.runTransactionOrRespondError $ do project@Project {projectId} <- Q.projectByShortHand projectShorthand `whenNothingM` throwSomeServerError (EntityMissing (ErrorID "project:missing") "Project not found") - (nextCursor, shareContributionTimeline) <- ContributionsQ.getPagedShareContributionTimelineByProjectIdAndNumber projectId contributionNumber (unCursor <$> mayCursor) limit + (nextCursor, shareContributionTimeline) <- ContributionsQ.getPagedShareContributionTimelineByProjectIdAndNumber projectId contributionNumber (location <$> mayCursor) limit shareContributionsTimelineWithUserInfo <- shareContributionTimeline & UsersQ.userDisplayInfoOf (traverse . traverse) pure (project, shareContributionsTimelineWithUserInfo, nextCursor) _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkContributionRead mayCallerUserId projectId - pure $ Paged {items = shareContributionTimeline, cursor = Cursor <$> nextCursor} + -- We don't currently support backwards pagination on contribution activity. + pure $ Paged {items = shareContributionTimeline, nextCursor = Cursor <$> nextCursor <*> pure Next, prevCursor = Nothing} where limit = fromMaybe 20 mayLimit projectShorthand = IDs.ProjectShortHand {userHandle, projectSlug} @@ -247,13 +248,13 @@ listContributionsByUserEndpoint :: Maybe ContributionKindFilter -> WebApp (Paged ListContributionsCursor (ShareContribution UserDisplayInfo)) listContributionsByUserEndpoint (AuthN.MaybeAuthedUserID mayCallerUserId) userHandle mayCursor mayLimit statusFilter kindFilter = do - (contributions, nextCursor) <- PG.runTransactionOrRespondError $ do + contributions <- PG.runTransactionOrRespondError $ do user <- UserQ.userByHandle userHandle `whenNothingM` throwError (EntityMissing (ErrorID "user:missing") "User not found") - (nextCursor, contributions) <- + contributions <- ContributionsQ.listContributionsByUserId mayCallerUserId (User.user_id user) limit mayCursor statusFilter kindFilter - >>= UsersQ.userDisplayInfoOf (_2 . traversed . traversed) - pure (contributions, nextCursor) - pure $ Paged {items = contributions, cursor = nextCursor} + >>= UsersQ.userDisplayInfoOf (traversed . traversed) + pure contributions + pure contributions where limit = fromMaybe 20 mayLimit diff --git a/src/Share/Web/Share/Releases/Impl.hs b/src/Share/Web/Share/Releases/Impl.hs index cd462278..50a95930 100644 --- a/src/Share/Web/Share/Releases/Impl.hs +++ b/src/Share/Web/Share/Releases/Impl.hs @@ -12,7 +12,6 @@ where import Control.Lens import Control.Monad.Except import Control.Monad.Trans.Maybe -import Data.List.NonEmpty qualified as NonEmpty import Data.Set qualified as Set import Servant import Share.Codebase qualified as Codebase @@ -376,17 +375,16 @@ listReleasesByProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle ( \release -> API.releaseToAPIRelease projectShortHand release ) - pure $ Paged {items = shareReleases, cursor = nextCursor releases} + releases + & pagedOn + ( \release -> + let Release {version = ReleaseVersion {major, minor, patch}, releaseId} = release + in (major, minor, patch, releaseId) + ) + & \p -> + p {items = shareReleases} + & pure where - nextCursor :: [Release causal userId] -> Maybe (Cursor ListReleasesCursor) - nextCursor releases = case releases of - [] -> Nothing - releases@(x : xs) - | length releases < fromIntegral (getLimit limit) -> Nothing - | otherwise -> - let Release {version = ReleaseVersion {major, minor, patch}, releaseId} = NonEmpty.last (x :| xs) - in Just $ Cursor (major, minor, patch, releaseId) - limit = fromMaybe defaultLimit mayLimit defaultLimit = Limit 20 defaultStatusFilter = AllReleases diff --git a/src/Share/Web/Share/Tickets/Impl.hs b/src/Share/Web/Share/Tickets/Impl.hs index 49c692b1..f37b6ead 100644 --- a/src/Share/Web/Share/Tickets/Impl.hs +++ b/src/Share/Web/Share/Tickets/Impl.hs @@ -87,10 +87,10 @@ listTicketsByProjectEndpoint (AuthN.MaybeAuthedUserID mayCallerUserId) handle pr User.user_id <$> UserQ.userByHandle authorHandle `whenNothingM` throwError (EntityMissing (ErrorID "user:missing") "User not found") pure (project, authorFilterID) _authReceipt <- AuthZ.permissionGuard $ AuthZ.checkTicketListByProject mayCallerUserId projectId - (nextCursor, tickets) <- PG.runTransaction do + tickets <- PG.runTransaction do TicketsQ.listTicketsByProjectId projectId limit cursor authorUserId statusFilter - >>= UserQ.userDisplayInfoOf (_2 . traversed . traversed) - pure $ Paged {items = tickets, cursor = nextCursor} + >>= UserQ.userDisplayInfoOf (traversed . traversed) + pure tickets where limit = fromMaybe 20 mayLimit projectShorthand = IDs.ProjectShortHand {userHandle = handle, projectSlug} @@ -164,13 +164,14 @@ getTicketTimelineEndpoint :: getTicketTimelineEndpoint (AuthN.MaybeAuthedUserID mayCallerUserId) userHandle projectSlug ticketNumber mayCursor mayLimit = do (projectId, shareTicketTimeline, nextCursor) <- PG.runTransactionOrRespondError $ do Project {projectId} <- Q.projectByShortHand projectShorthand `whenNothingM` throwSomeServerError (EntityMissing (ErrorID "project:missing") "Project not found") - (nextCursor, shareTicketTimeline) <- TicketsQ.getPagedShareTicketTimelineByProjectIdAndNumber projectId ticketNumber (unCursor <$> mayCursor) limit + (nextCursor, shareTicketTimeline) <- TicketsQ.getPagedShareTicketTimelineByProjectIdAndNumber projectId ticketNumber (location <$> mayCursor) limit shareTicketsTimelineWithUserInfo <- shareTicketTimeline & UserQ.userDisplayInfoOf (traverse . traverse) pure (projectId, shareTicketsTimelineWithUserInfo, nextCursor) _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkTicketRead mayCallerUserId projectId - pure $ Paged {items = shareTicketTimeline, cursor = Cursor <$> nextCursor} + -- We don't currently support backwards pagination on timelines. + pure $ Paged {items = shareTicketTimeline, nextCursor = Cursor <$> nextCursor <*> pure Next, prevCursor = Nothing} where limit = fromMaybe 20 mayLimit projectShorthand = IDs.ProjectShortHand {userHandle, projectSlug} @@ -183,12 +184,12 @@ listTicketsByUserEndpoint :: Maybe TicketStatus -> WebApp (Paged ListTicketsCursor (ShareTicket UserDisplayInfo)) listTicketsByUserEndpoint (AuthN.MaybeAuthedUserID mayCallerUserId) userHandle mayCursor mayLimit statusFilter = do - (tickets, nextCursor) <- PG.runTransactionOrRespondError $ do + tickets <- PG.runTransactionOrRespondError $ do user <- UserQ.userByHandle userHandle `whenNothingM` throwError (EntityMissing (ErrorID "user:missing") "User not found") - (nextCursor, tickets) <- + tickets <- TicketsQ.listTicketsByUserId mayCallerUserId (User.user_id user) limit mayCursor statusFilter - >>= UserQ.userDisplayInfoOf (_2 . traversed . traversed) - pure (tickets, nextCursor) - pure $ Paged {items = tickets, cursor = nextCursor} + >>= UserQ.userDisplayInfoOf (traversed . traversed) + pure tickets + pure tickets where limit = fromMaybe 20 mayLimit From 3082cc7570564849ff41c0e40697625774dde0db Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Thu, 22 May 2025 15:32:22 -0700 Subject: [PATCH 3/6] Rerun transcripts --- .../share-apis/branches/branch-list-by-self-name-prefix.json | 5 +++-- .../share-apis/branches/branch-list-by-self-project-ref.json | 5 +++-- .../share-apis/branches/branch-list-by-user-self.json | 5 +++-- transcripts/share-apis/branches/branch-list-by-user.json | 5 +++-- .../share-apis/branches/branch-list-contributor-filter.json | 5 +++-- .../share-apis/branches/branch-list-contributor-prefix.json | 5 +++-- .../share-apis/branches/branch-list-contributors-only.json | 5 +++-- transcripts/share-apis/branches/branch-list-core-only.json | 5 +++-- .../share-apis/branches/branch-list-inaccessible.json | 5 +++-- transcripts/share-apis/branches/branch-list-name-filter.json | 5 +++-- transcripts/share-apis/branches/branch-list-name-prefix.json | 5 +++-- transcripts/share-apis/branches/branch-list-private.json | 5 +++-- transcripts/share-apis/branches/branch-list.json | 5 +++-- .../contributions/contribution-list-author-filter.json | 5 +++-- .../contributions/contribution-list-kind-filter.json | 5 +++-- .../contributions/contribution-list-status-filter.json | 5 +++-- transcripts/share-apis/contributions/contribution-list.json | 5 +++-- .../share-apis/contributions/contribution-timeline-get.json | 5 +++-- transcripts/share-apis/releases/release-prefix-search.json | 5 +++-- .../share-apis/releases/releases-list-published-only.json | 5 +++-- .../share-apis/releases/releases-list-version-search.json | 5 +++-- transcripts/share-apis/releases/releases-list.json | 5 +++-- .../share-apis/tickets/ticket-list-author-filter.json | 5 +++-- .../share-apis/tickets/ticket-list-status-filter.json | 5 +++-- transcripts/share-apis/tickets/ticket-list.json | 5 +++-- transcripts/share-apis/tickets/ticket-timeline-get.json | 5 +++-- transcripts/transcript_helpers.sh | 2 +- 27 files changed, 79 insertions(+), 53 deletions(-) diff --git a/transcripts/share-apis/branches/branch-list-by-self-name-prefix.json b/transcripts/share-apis/branches/branch-list-by-self-name-prefix.json index 8bbe4a42..71aba781 100644 --- a/transcripts/share-apis/branches/branch-list-by-self-name-prefix.json +++ b/transcripts/share-apis/branches/branch-list-by-self-name-prefix.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -61,7 +60,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-by-self-project-ref.json b/transcripts/share-apis/branches/branch-list-by-self-project-ref.json index 8bbe4a42..71aba781 100644 --- a/transcripts/share-apis/branches/branch-list-by-self-project-ref.json +++ b/transcripts/share-apis/branches/branch-list-by-self-project-ref.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -61,7 +60,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-by-user-self.json b/transcripts/share-apis/branches/branch-list-by-user-self.json index c83a1f56..e65072f1 100644 --- a/transcripts/share-apis/branches/branch-list-by-user-self.json +++ b/transcripts/share-apis/branches/branch-list-by-user-self.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -81,7 +80,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-by-user.json b/transcripts/share-apis/branches/branch-list-by-user.json index f1b0c83a..ae1d216c 100644 --- a/transcripts/share-apis/branches/branch-list-by-user.json +++ b/transcripts/share-apis/branches/branch-list-by-user.json @@ -1,7 +1,8 @@ { "body": { - "cursor": null, - "items": [] + "items": [], + "nextCursor": null, + "prevCursor": null }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-contributor-filter.json b/transcripts/share-apis/branches/branch-list-contributor-filter.json index eb2b51f9..2584de7a 100644 --- a/transcripts/share-apis/branches/branch-list-contributor-filter.json +++ b/transcripts/share-apis/branches/branch-list-contributor-filter.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -101,7 +100,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-contributor-prefix.json b/transcripts/share-apis/branches/branch-list-contributor-prefix.json index eb2b51f9..2584de7a 100644 --- a/transcripts/share-apis/branches/branch-list-contributor-prefix.json +++ b/transcripts/share-apis/branches/branch-list-contributor-prefix.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -101,7 +100,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-contributors-only.json b/transcripts/share-apis/branches/branch-list-contributors-only.json index 8bbe4a42..71aba781 100644 --- a/transcripts/share-apis/branches/branch-list-contributors-only.json +++ b/transcripts/share-apis/branches/branch-list-contributors-only.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -61,7 +60,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-core-only.json b/transcripts/share-apis/branches/branch-list-core-only.json index 9d6f0d78..4f5a5454 100644 --- a/transcripts/share-apis/branches/branch-list-core-only.json +++ b/transcripts/share-apis/branches/branch-list-core-only.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "feature", @@ -42,7 +41,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-inaccessible.json b/transcripts/share-apis/branches/branch-list-inaccessible.json index f1b0c83a..ae1d216c 100644 --- a/transcripts/share-apis/branches/branch-list-inaccessible.json +++ b/transcripts/share-apis/branches/branch-list-inaccessible.json @@ -1,7 +1,8 @@ { "body": { - "cursor": null, - "items": [] + "items": [], + "nextCursor": null, + "prevCursor": null }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-name-filter.json b/transcripts/share-apis/branches/branch-list-name-filter.json index b5893a39..935a00e8 100644 --- a/transcripts/share-apis/branches/branch-list-name-filter.json +++ b/transcripts/share-apis/branches/branch-list-name-filter.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "main", @@ -22,7 +21,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-name-prefix.json b/transcripts/share-apis/branches/branch-list-name-prefix.json index 8bbe4a42..71aba781 100644 --- a/transcripts/share-apis/branches/branch-list-name-prefix.json +++ b/transcripts/share-apis/branches/branch-list-name-prefix.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -61,7 +60,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list-private.json b/transcripts/share-apis/branches/branch-list-private.json index c83a1f56..e65072f1 100644 --- a/transcripts/share-apis/branches/branch-list-private.json +++ b/transcripts/share-apis/branches/branch-list-private.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -81,7 +80,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/branches/branch-list.json b/transcripts/share-apis/branches/branch-list.json index eb2b51f9..2584de7a 100644 --- a/transcripts/share-apis/branches/branch-list.json +++ b/transcripts/share-apis/branches/branch-list.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "branchRef": "@transcripts/contribution", @@ -101,7 +100,9 @@ }, "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/contributions/contribution-list-author-filter.json b/transcripts/share-apis/contributions/contribution-list-author-filter.json index 2a3e5ff4..74561bc4 100644 --- a/transcripts/share-apis/contributions/contribution-list-author-filter.json +++ b/transcripts/share-apis/contributions/contribution-list-author-filter.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "author": { @@ -78,7 +77,9 @@ "title": "Fix issue with user authentication", "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/contributions/contribution-list-kind-filter.json b/transcripts/share-apis/contributions/contribution-list-kind-filter.json index 5449884d..58f97c49 100644 --- a/transcripts/share-apis/contributions/contribution-list-kind-filter.json +++ b/transcripts/share-apis/contributions/contribution-list-kind-filter.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "author": { @@ -21,7 +20,9 @@ "title": "My contribution", "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/contributions/contribution-list-status-filter.json b/transcripts/share-apis/contributions/contribution-list-status-filter.json index e6f34bdf..65db1d3c 100644 --- a/transcripts/share-apis/contributions/contribution-list-status-filter.json +++ b/transcripts/share-apis/contributions/contribution-list-status-filter.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "author": { @@ -59,7 +58,9 @@ "title": "Fix issue with user authentication", "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/contributions/contribution-list.json b/transcripts/share-apis/contributions/contribution-list.json index 045b9607..7b631c32 100644 --- a/transcripts/share-apis/contributions/contribution-list.json +++ b/transcripts/share-apis/contributions/contribution-list.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "author": { @@ -97,7 +96,9 @@ "title": "Fix issue with user authentication", "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/contributions/contribution-timeline-get.json b/transcripts/share-apis/contributions/contribution-timeline-get.json index f2d2e757..cb1b922d 100644 --- a/transcripts/share-apis/contributions/contribution-timeline-get.json +++ b/transcripts/share-apis/contributions/contribution-timeline-get.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "actor": { @@ -48,7 +47,9 @@ "revision": 0, "timestamp": "" } - ] + ], + "nextCursor": "", + "prevCursor": null }, "status": [ { diff --git a/transcripts/share-apis/releases/release-prefix-search.json b/transcripts/share-apis/releases/release-prefix-search.json index 67b0f43a..2a1f859c 100644 --- a/transcripts/share-apis/releases/release-prefix-search.json +++ b/transcripts/share-apis/releases/release-prefix-search.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "causalHashSquashed": "#sg60bvjo91fsoo7pkh9gejbn0qgc95vra87ap6l5d35ri0lkaudl7bs12d71sf3fh6p23teemuor7mk1i9n567m50ibakcghjec5ajg", @@ -16,7 +15,9 @@ "updatedAt": "", "version": "4.5.6" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/releases/releases-list-published-only.json b/transcripts/share-apis/releases/releases-list-published-only.json index 3f598dfa..728e53d2 100644 --- a/transcripts/share-apis/releases/releases-list-published-only.json +++ b/transcripts/share-apis/releases/releases-list-published-only.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "causalHashSquashed": "#sg60bvjo91fsoo7pkh9gejbn0qgc95vra87ap6l5d35ri0lkaudl7bs12d71sf3fh6p23teemuor7mk1i9n567m50ibakcghjec5ajg", @@ -30,7 +29,9 @@ "updatedAt": "", "version": "1.0.0" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/releases/releases-list-version-search.json b/transcripts/share-apis/releases/releases-list-version-search.json index 3bb05644..3375f2a6 100644 --- a/transcripts/share-apis/releases/releases-list-version-search.json +++ b/transcripts/share-apis/releases/releases-list-version-search.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "causalHashSquashed": "#sg60bvjo91fsoo7pkh9gejbn0qgc95vra87ap6l5d35ri0lkaudl7bs12d71sf3fh6p23teemuor7mk1i9n567m50ibakcghjec5ajg", @@ -16,7 +15,9 @@ "updatedAt": "", "version": "1.2.3" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/releases/releases-list.json b/transcripts/share-apis/releases/releases-list.json index 3f598dfa..728e53d2 100644 --- a/transcripts/share-apis/releases/releases-list.json +++ b/transcripts/share-apis/releases/releases-list.json @@ -1,6 +1,5 @@ { "body": { - "cursor": null, "items": [ { "causalHashSquashed": "#sg60bvjo91fsoo7pkh9gejbn0qgc95vra87ap6l5d35ri0lkaudl7bs12d71sf3fh6p23teemuor7mk1i9n567m50ibakcghjec5ajg", @@ -30,7 +29,9 @@ "updatedAt": "", "version": "1.0.0" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/tickets/ticket-list-author-filter.json b/transcripts/share-apis/tickets/ticket-list-author-filter.json index b4d1ba3c..8bb8fb66 100644 --- a/transcripts/share-apis/tickets/ticket-list-author-filter.json +++ b/transcripts/share-apis/tickets/ticket-list-author-filter.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "author": { @@ -36,7 +35,9 @@ "title": "Completed Request", "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/tickets/ticket-list-status-filter.json b/transcripts/share-apis/tickets/ticket-list-status-filter.json index 56b44197..b6700be3 100644 --- a/transcripts/share-apis/tickets/ticket-list-status-filter.json +++ b/transcripts/share-apis/tickets/ticket-list-status-filter.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "author": { @@ -36,7 +35,9 @@ "title": "Bug Report", "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/tickets/ticket-list.json b/transcripts/share-apis/tickets/ticket-list.json index 0a28ee86..fff42130 100644 --- a/transcripts/share-apis/tickets/ticket-list.json +++ b/transcripts/share-apis/tickets/ticket-list.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "author": { @@ -53,7 +52,9 @@ "title": "Completed Request", "updatedAt": "" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/tickets/ticket-timeline-get.json b/transcripts/share-apis/tickets/ticket-timeline-get.json index 75084140..57435ee3 100644 --- a/transcripts/share-apis/tickets/ticket-timeline-get.json +++ b/transcripts/share-apis/tickets/ticket-timeline-get.json @@ -1,6 +1,5 @@ { "body": { - "cursor": "", "items": [ { "actor": { @@ -48,7 +47,9 @@ "revision": 0, "timestamp": "" } - ] + ], + "nextCursor": "", + "prevCursor": null }, "status": [ { diff --git a/transcripts/transcript_helpers.sh b/transcripts/transcript_helpers.sh index 5a25f795..7eb0166a 100755 --- a/transcripts/transcript_helpers.sh +++ b/transcripts/transcript_helpers.sh @@ -115,7 +115,7 @@ clean_for_transcript() { # Replace all uuids in stdin with the string "" sed -E 's/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}//g' | \ # Replace all cursors in stdin with the string "" - sed -E 's/"cursor": ?"[^"]+"/"cursor": ""/g' | \ + sed -E 's/Cursor": ?"[^"]+"/Cursor": ""/g' | \ # Replace all JWTs in stdin with the string "" sed -E 's/eyJ([[:alnum:]_.-]+)//g' | \ # In docker we network things together with host names, but locally we just use localhost; so From d298f28d35a501d7a679284ad7f8a455bbd37565 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Tue, 27 May 2025 11:21:10 -0700 Subject: [PATCH 4/6] Add paging to notifications hub --- src/Share/Notifications/API.hs | 8 ++++++-- src/Share/Notifications/Impl.hs | 14 +++++++++++--- src/Share/Notifications/Queries.hs | 15 ++++++++++----- src/Share/Notifications/Types.hs | 11 +++++++---- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/Share/Notifications/API.hs b/src/Share/Notifications/API.hs index d77aee69..a7516a3e 100644 --- a/src/Share/Notifications/API.hs +++ b/src/Share/Notifications/API.hs @@ -10,6 +10,7 @@ module Share.Notifications.API EmailRoutes (..), WebhookRoutes (..), GetHubEntriesResponse (..), + GetHubEntriesCursor, StatusFilter (..), UpdateHubEntriesRequest (..), GetSubscriptionsResponse (..), @@ -42,6 +43,7 @@ import Share.IDs import Share.Notifications.Types (DeliveryMethodId, HydratedEvent, NotificationDeliveryMethod, NotificationHubEntry, NotificationStatus, NotificationSubscription, NotificationTopic, NotificationTopicGroup, SubscriptionFilter) import Share.OAuth.Session (AuthenticatedUserId) import Share.Prelude +import Share.Utils.API (Cursor, Paged) import Share.Utils.URI (URIParam) import Share.Web.Share.DisplayInfo.Types (UnifiedDisplayInfo) @@ -209,15 +211,17 @@ instance FromJSON StatusFilter where Nothing -> fail "Empty status filter" Just statuses -> pure $ StatusFilter $ NESet.fromList statuses +type GetHubEntriesCursor = (UTCTime, NotificationHubEntryId) + type GetHubEntriesEndpoint = AuthenticatedUserId :> QueryParam "limit" Int - :> QueryParam "after" UTCTime + :> QueryParam "cursor" (Cursor GetHubEntriesCursor) :> QueryParam "status" StatusFilter :> Get '[JSON] GetHubEntriesResponse data GetHubEntriesResponse = GetHubEntriesResponse - { notifications :: [NotificationHubEntry UnifiedDisplayInfo HydratedEvent] + { notifications :: Paged GetHubEntriesCursor (NotificationHubEntry UnifiedDisplayInfo HydratedEvent) } instance ToJSON GetHubEntriesResponse where diff --git a/src/Share/Notifications/Impl.hs b/src/Share/Notifications/Impl.hs index 5f962f75..97388449 100644 --- a/src/Share/Notifications/Impl.hs +++ b/src/Share/Notifications/Impl.hs @@ -5,12 +5,16 @@ import Data.Time import Servant import Servant.Server.Generic (AsServerT) import Share.IDs +import Share.Notifications.API (GetHubEntriesCursor) import Share.Notifications.API qualified as API import Share.Notifications.Ops qualified as NotifOps import Share.Notifications.Queries qualified as NotificationQ +import Share.Notifications.Types import Share.Postgres qualified as PG import Share.Postgres.Ops qualified as UserQ +import Share.Prelude import Share.User (User (..)) +import Share.Utils.API (Cursor, pagedOn) import Share.Web.App import Share.Web.Authorization qualified as AuthZ @@ -77,14 +81,18 @@ server userHandle = subscriptionsRoutes = subscriptionsRoutes userHandle } -getHubEntriesEndpoint :: UserHandle -> UserId -> Maybe Int -> Maybe UTCTime -> Maybe API.StatusFilter -> WebApp API.GetHubEntriesResponse -getHubEntriesEndpoint userHandle callerUserId limit afterTime mayStatusFilter = do +getHubEntriesEndpoint :: UserHandle -> UserId -> Maybe Int -> Maybe (Cursor GetHubEntriesCursor) -> Maybe API.StatusFilter -> WebApp API.GetHubEntriesResponse +getHubEntriesEndpoint userHandle callerUserId limit mayCursor mayStatusFilter = do User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkNotificationsGet callerUserId notificationUserId notifications <- PG.runTransaction do notifs <- NotificationQ.listNotificationHubEntryPayloads notificationUserId limit afterTime (API.getStatusFilter <$> mayStatusFilter) forOf (traversed . traversed) notifs NotifOps.hydrateEvent - pure $ API.GetHubEntriesResponse {notifications} + paged <- + notifications + & pagedOn (\(NotificationHubEntry {hubEntryId, hubEntryCreatedAt}) -> (hubEntryCreatedAt, hubEntryId)) + & pure + pure $ API.GetHubEntriesResponse {notifications = paged} updateHubEntriesEndpoint :: UserHandle -> UserId -> API.UpdateHubEntriesRequest -> WebApp () updateHubEntriesEndpoint userHandle callerUserId API.UpdateHubEntriesRequest {notificationStatus, notificationIds} = do diff --git a/src/Share/Notifications/Queries.hs b/src/Share/Notifications/Queries.hs index f82ad314..263e9e4f 100644 --- a/src/Share/Notifications/Queries.hs +++ b/src/Share/Notifications/Queries.hs @@ -26,15 +26,16 @@ import Control.Lens import Data.Foldable qualified as Foldable import Data.Ord (clamp) import Data.Set.NonEmpty (NESet) -import Data.Time (UTCTime) import Share.Contribution import Share.IDs +import Share.Notifications.API (GetHubEntriesCursor) import Share.Notifications.Types import Share.Postgres import Share.Postgres qualified as PG import Share.Postgres.Contributions.Queries qualified as ContributionQ import Share.Postgres.Users.Queries qualified as UsersQ import Share.Prelude +import Share.Utils.API (Cursor (..), CursorDirection (..)) import Share.Web.Share.DisplayInfo.Queries qualified as DisplayInfoQ import Share.Web.Share.DisplayInfo.Types (UnifiedDisplayInfo) @@ -55,19 +56,23 @@ expectEvent eventId = do WHERE id = #{eventId} |] -listNotificationHubEntryPayloads :: UserId -> Maybe Int -> Maybe UTCTime -> Maybe (NESet NotificationStatus) -> Transaction e [NotificationHubEntry UnifiedDisplayInfo HydratedEventPayload] -listNotificationHubEntryPayloads notificationUserId mayLimit afterTime statusFilter = do +listNotificationHubEntryPayloads :: UserId -> Maybe Int -> Maybe (Cursor GetHubEntriesCursor) -> Maybe (NESet NotificationStatus) -> Transaction e [NotificationHubEntry UnifiedDisplayInfo HydratedEventPayload] +listNotificationHubEntryPayloads notificationUserId mayLimit mayCursor statusFilter = do let limit = clamp (0, 1000) . fromIntegral @Int @Int32 . fromMaybe 50 $ mayLimit let statusFilterList = Foldable.toList <$> statusFilter + let cursorFilter = case mayCursor of + Nothing -> mempty + Just (Cursor (beforeTime, entryId) Previous) -> [PG.sql| AND (hub.created_at, hub.id) < (#{beforeTime}, #{entryId})|] + Just (Cursor (afterTime, entryId) Next) -> [PG.sql| AND (hub.created_at, hub.id) > (#{afterTime}, #{entryId})|] dbNotifications <- queryListRows @(NotificationHubEntry UserId NotificationEventData) [sql| - SELECT hub.id, hub.status, event.id, event.occurred_at, event.scope_user_id, event.actor_user_id, event.resource_id, event.topic, event.data + SELECT hub.id, hub.status, hub.created_at, event.id, event.occurred_at, event.scope_user_id, event.actor_user_id, event.resource_id, event.topic, event.data FROM notification_hub_entries hub JOIN notification_events event ON hub.event_id = event.id WHERE hub.user_id = #{notificationUserId} AND (#{statusFilterList} IS NULL OR hub.status = ANY(#{statusFilterList}::notification_status[])) - AND (#{afterTime} IS NULL OR event.occurred_at > #{afterTime}) + (^{cursorFilter}) ORDER BY hub.created_at DESC LIMIT #{limit} |] diff --git a/src/Share/Notifications/Types.hs b/src/Share/Notifications/Types.hs index 18110032..e6285222 100644 --- a/src/Share/Notifications/Types.hs +++ b/src/Share/Notifications/Types.hs @@ -390,24 +390,27 @@ instance Aeson.ToJSON (NotificationSubscription NotificationSubscriptionId) wher data NotificationHubEntry userInfo eventPayload = NotificationHubEntry { hubEntryId :: NotificationHubEntryId, hubEntryEvent :: NotificationEvent NotificationEventId userInfo UTCTime eventPayload, - hubEntryStatus :: NotificationStatus + hubEntryStatus :: NotificationStatus, + hubEntryCreatedAt :: UTCTime } deriving stock (Functor, Foldable, Traversable) instance (Aeson.ToJSON eventPayload, Aeson.ToJSON userInfo) => Aeson.ToJSON (NotificationHubEntry userInfo eventPayload) where - toJSON NotificationHubEntry {hubEntryId, hubEntryEvent, hubEntryStatus} = + toJSON NotificationHubEntry {hubEntryId, hubEntryEvent, hubEntryStatus, hubEntryCreatedAt} = Aeson.object [ "id" Aeson..= hubEntryId, "event" Aeson..= hubEntryEvent, - "status" Aeson..= hubEntryStatus + "status" Aeson..= hubEntryStatus, + "createdAt" Aeson..= hubEntryCreatedAt ] instance Hasql.DecodeRow (NotificationHubEntry UserId NotificationEventData) where decodeRow = do hubEntryId <- PG.decodeField hubEntryStatus <- PG.decodeField + hubEntryCreatedAt <- PG.decodeField hubEntryEvent <- PG.decodeRow - pure $ NotificationHubEntry {hubEntryId, hubEntryEvent, hubEntryStatus} + pure $ NotificationHubEntry {hubEntryId, hubEntryEvent, hubEntryStatus, hubEntryCreatedAt} hubEntryUserInfo_ :: Traversal (NotificationHubEntry userInfo eventPayload) (NotificationHubEntry userInfo' eventPayload) userInfo userInfo' hubEntryUserInfo_ f (NotificationHubEntry {hubEntryEvent, ..}) = do From e3ed644d42447332436639c397df1dfd1120b14c Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Tue, 27 May 2025 11:59:55 -0700 Subject: [PATCH 5/6] Fix up hub paging --- src/Share/Notifications/API.hs | 11 +---------- src/Share/Notifications/Impl.hs | 27 +++++++++++++++------------ src/Share/Notifications/Queries.hs | 2 +- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/Share/Notifications/API.hs b/src/Share/Notifications/API.hs index a7516a3e..92c5d210 100644 --- a/src/Share/Notifications/API.hs +++ b/src/Share/Notifications/API.hs @@ -9,7 +9,6 @@ module Share.Notifications.API SubscriptionRoutes (..), EmailRoutes (..), WebhookRoutes (..), - GetHubEntriesResponse (..), GetHubEntriesCursor, StatusFilter (..), UpdateHubEntriesRequest (..), @@ -218,15 +217,7 @@ type GetHubEntriesEndpoint = :> QueryParam "limit" Int :> QueryParam "cursor" (Cursor GetHubEntriesCursor) :> QueryParam "status" StatusFilter - :> Get '[JSON] GetHubEntriesResponse - -data GetHubEntriesResponse = GetHubEntriesResponse - { notifications :: Paged GetHubEntriesCursor (NotificationHubEntry UnifiedDisplayInfo HydratedEvent) - } - -instance ToJSON GetHubEntriesResponse where - toJSON GetHubEntriesResponse {notifications} = - object ["notifications" .= notifications] + :> Get '[JSON] (Paged GetHubEntriesCursor (NotificationHubEntry UnifiedDisplayInfo HydratedEvent)) type UpdateHubEntriesEndpoint = AuthenticatedUserId diff --git a/src/Share/Notifications/Impl.hs b/src/Share/Notifications/Impl.hs index 97388449..2bed5fe9 100644 --- a/src/Share/Notifications/Impl.hs +++ b/src/Share/Notifications/Impl.hs @@ -1,7 +1,6 @@ module Share.Notifications.Impl (server) where -import Control.Lens (forOf, traversed) -import Data.Time +import Control.Lens import Servant import Servant.Server.Generic (AsServerT) import Share.IDs @@ -12,11 +11,11 @@ import Share.Notifications.Queries qualified as NotificationQ import Share.Notifications.Types import Share.Postgres qualified as PG import Share.Postgres.Ops qualified as UserQ -import Share.Prelude import Share.User (User (..)) -import Share.Utils.API (Cursor, pagedOn) +import Share.Utils.API (Cursor, Paged, pagedOn) import Share.Web.App import Share.Web.Authorization qualified as AuthZ +import Share.Web.Share.DisplayInfo.Types (UnifiedDisplayInfo) hubRoutes :: UserHandle -> API.HubEntriesRoutes (AsServerT WebApp) hubRoutes userHandle = @@ -81,18 +80,22 @@ server userHandle = subscriptionsRoutes = subscriptionsRoutes userHandle } -getHubEntriesEndpoint :: UserHandle -> UserId -> Maybe Int -> Maybe (Cursor GetHubEntriesCursor) -> Maybe API.StatusFilter -> WebApp API.GetHubEntriesResponse +getHubEntriesEndpoint :: + UserHandle -> + UserId -> + Maybe Int -> + Maybe (Cursor GetHubEntriesCursor) -> + Maybe API.StatusFilter -> + WebApp (Paged GetHubEntriesCursor (NotificationHubEntry UnifiedDisplayInfo HydratedEvent)) getHubEntriesEndpoint userHandle callerUserId limit mayCursor mayStatusFilter = do User {user_id = notificationUserId} <- UserQ.expectUserByHandle userHandle _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkNotificationsGet callerUserId notificationUserId - notifications <- PG.runTransaction do - notifs <- NotificationQ.listNotificationHubEntryPayloads notificationUserId limit afterTime (API.getStatusFilter <$> mayStatusFilter) + notifications <- PG.runTransaction $ do + notifs <- NotificationQ.listNotificationHubEntryPayloads notificationUserId limit mayCursor (API.getStatusFilter <$> mayStatusFilter) forOf (traversed . traversed) notifs NotifOps.hydrateEvent - paged <- - notifications - & pagedOn (\(NotificationHubEntry {hubEntryId, hubEntryCreatedAt}) -> (hubEntryCreatedAt, hubEntryId)) - & pure - pure $ API.GetHubEntriesResponse {notifications = paged} + notifications + & pagedOn (\(NotificationHubEntry {hubEntryId, hubEntryCreatedAt}) -> (hubEntryCreatedAt, hubEntryId)) + & pure updateHubEntriesEndpoint :: UserHandle -> UserId -> API.UpdateHubEntriesRequest -> WebApp () updateHubEntriesEndpoint userHandle callerUserId API.UpdateHubEntriesRequest {notificationStatus, notificationIds} = do diff --git a/src/Share/Notifications/Queries.hs b/src/Share/Notifications/Queries.hs index 263e9e4f..6127bc46 100644 --- a/src/Share/Notifications/Queries.hs +++ b/src/Share/Notifications/Queries.hs @@ -72,7 +72,7 @@ listNotificationHubEntryPayloads notificationUserId mayLimit mayCursor statusFil JOIN notification_events event ON hub.event_id = event.id WHERE hub.user_id = #{notificationUserId} AND (#{statusFilterList} IS NULL OR hub.status = ANY(#{statusFilterList}::notification_status[])) - (^{cursorFilter}) + ^{cursorFilter} ORDER BY hub.created_at DESC LIMIT #{limit} |] From 9449750b3241ea8aa4c6ce32dbe1fe752686dc26 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Tue, 27 May 2025 11:59:55 -0700 Subject: [PATCH 6/6] Rerun transcripts --- .../list-notifications-read-transcripts.json | 7 +++++-- .../notifications/list-notifications-transcripts.json | 8 ++++++-- .../notifications/list-notifications-unread-test.json | 4 +++- transcripts/share-apis/notifications/run.zsh | 4 ++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/transcripts/share-apis/notifications/list-notifications-read-transcripts.json b/transcripts/share-apis/notifications/list-notifications-read-transcripts.json index 8ae88fd0..722710de 100644 --- a/transcripts/share-apis/notifications/list-notifications-read-transcripts.json +++ b/transcripts/share-apis/notifications/list-notifications-read-transcripts.json @@ -1,7 +1,8 @@ { "body": { - "notifications": [ + "items": [ { + "createdAt": "", "event": { "actor": { "info": { @@ -48,7 +49,9 @@ "id": "NOT-", "status": "read" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/notifications/list-notifications-transcripts.json b/transcripts/share-apis/notifications/list-notifications-transcripts.json index 4e41daac..4fafdc6d 100644 --- a/transcripts/share-apis/notifications/list-notifications-transcripts.json +++ b/transcripts/share-apis/notifications/list-notifications-transcripts.json @@ -1,7 +1,8 @@ { "body": { - "notifications": [ + "items": [ { + "createdAt": "", "event": { "actor": { "info": { @@ -49,6 +50,7 @@ "status": "unread" }, { + "createdAt": "", "event": { "actor": { "info": { @@ -116,7 +118,9 @@ "id": "NOT-", "status": "unread" } - ] + ], + "nextCursor": "", + "prevCursor": "" }, "status": [ { diff --git a/transcripts/share-apis/notifications/list-notifications-unread-test.json b/transcripts/share-apis/notifications/list-notifications-unread-test.json index e8fdfc6d..ae1d216c 100644 --- a/transcripts/share-apis/notifications/list-notifications-unread-test.json +++ b/transcripts/share-apis/notifications/list-notifications-unread-test.json @@ -1,6 +1,8 @@ { "body": { - "notifications": [] + "items": [], + "nextCursor": null, + "prevCursor": null }, "status": [ { diff --git a/transcripts/share-apis/notifications/run.zsh b/transcripts/share-apis/notifications/run.zsh index 00531243..551a6ada 100755 --- a/transcripts/share-apis/notifications/run.zsh +++ b/transcripts/share-apis/notifications/run.zsh @@ -79,8 +79,8 @@ fetch "$test_user" POST branch-create '/ucm/v1/projects/create-project-branch' " fetch "$unauthorized_user" GET notifications-get-unauthorized '/users/test/notifications/hub' -test_notification_id=$(fetch_data_jq "$test_user" GET list-notifications-test '/users/test/notifications/hub' '.notifications[0].id') -transcripts_notification_id=$(fetch_data_jq "$transcripts_user" GET list-notifications-transcripts '/users/transcripts/notifications/hub' '.notifications[0].id') +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 "$transcripts_user" GET list-notifications-transcripts '/users/transcripts/notifications/hub'