diff --git a/changelog.d/1-api-changes/WPB-25136-do-not-count-apps-as-paying-users b/changelog.d/1-api-changes/WPB-25136-do-not-count-apps-as-paying-users new file mode 100644 index 00000000000..940164d7ee0 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-25136-do-not-count-apps-as-paying-users @@ -0,0 +1 @@ +`GET /teams/:tid/size` response body lists `teamSizeRegulars`, `teamSizeApps`. diff --git a/changelog.d/3-bug-fixes/WPB-25136-do-not-count-apps-as-paying-users b/changelog.d/3-bug-fixes/WPB-25136-do-not-count-apps-as-paying-users new file mode 100644 index 00000000000..0783281d1b5 --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-25136-do-not-count-apps-as-paying-users @@ -0,0 +1 @@ +Do not count apps as paying users. diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index d5cc1d33bf9..56b0d0b5867 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -932,6 +932,12 @@ activateSend domain email locale = do req <- rawBaseRequest domain Brig Versioned $ joinHttpPath ["activate", "send"] submit "POST" $ req & addJSONObject (["email" .= email] <> maybeToList (((.=) "locale") <$> locale)) +-- https://staging-nginz-https.zinfra.io/v16/api/swagger-ui/#/default/get-team-size +getTeamSize :: (HasCallStack, MakesValue user) => user -> String -> App Response +getTeamSize user tid = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", tid, "size"] + submit "GET" req + acceptTeamInvitation :: (HasCallStack, MakesValue user) => user -> String -> Maybe String -> App Response acceptTeamInvitation user code mPw = do req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", "invitations", "accept"] diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index ff9ab3d1357..4a0051e8907 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -186,6 +186,11 @@ refreshIndex domain = do res <- submit "POST" req res.status `shouldMatchInt` 200 +getTeamSize :: (HasCallStack, MakesValue caller) => caller -> String -> App Response +getTeamSize caller tid = do + req <- baseRequest caller Brig Unversioned $ joinHttpPath ["i", "teams", tid, "size"] + submit "GET" req + addFederationRemoteTeam :: (HasCallStack, MakesValue domain, MakesValue remoteDomain, MakesValue team) => domain -> remoteDomain -> team -> App () addFederationRemoteTeam domain remoteDomain team = do void $ addFederationRemoteTeam' domain remoteDomain team >>= getBody 200 diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index 72eaa5e637f..cdce0eb5b77 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -19,7 +19,7 @@ module Test.Apps where -import API.Brig +import API.Brig as Brig import qualified API.BrigInternal as BrigI import API.Common import API.Galley @@ -28,6 +28,7 @@ import Data.Aeson.QQ.Simple import MLS.Util import Notifications import SetupHelpers +import System.Random (randomRIO) import Testlib.Prelude testCreateGetApp :: (HasCallStack) => Domain -> App () @@ -561,3 +562,34 @@ testAppReceivesMemberJoinNotification = do memberJoinApp <- awaitMatch isTeamMemberJoinNotif wsApp memberJoinApp %. "payload.0.team" `shouldMatch` tid memberJoinApp %. "payload.0.data.user" `shouldMatch` objId newMember + +testTeamSizeWithApps :: (HasCallStack) => TaggedBool "test internal api" -> App () +testTeamSizeWithApps (TaggedBool testInternalApi) = do + domain <- make OwnDomain + numRegulars <- liftIO $ randomRIO (1 :: Int, 3) + numApps <- liftIO $ randomRIO (1 :: Int, 3) + + (owner, tid, extraMembers) <- createTeam domain (numRegulars + 1) + + apps <- replicateM numApps $ bindResponse (createApp owner tid def) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "user" + + let checkSize :: (HasCallStack) => Int -> Int -> App () + checkSize wantRegulars wantApps = + (if testInternalApi then BrigI.getTeamSize else Brig.getTeamSize) owner tid `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "teamSize" `shouldMatchInt` (1 + wantRegulars + wantApps) + resp.json %. "teamSizeRegulars" `shouldMatchInt` (1 + wantRegulars) + resp.json %. "teamSizeApps" `shouldMatchInt` wantApps + + BrigI.refreshIndex domain + eventually $ do + checkSize numRegulars numApps + + deleteTeamMember tid owner (head apps) >>= assertSuccess + deleteTeamMember tid owner (head extraMembers) >>= assertSuccess + + BrigI.refreshIndex domain + eventually $ do + checkSize (numRegulars - 1) (numApps - 1) diff --git a/libs/types-common-journal/proto/TeamEvents.proto b/libs/types-common-journal/proto/TeamEvents.proto index 8dc80757cd8..8bd25c21cc8 100644 --- a/libs/types-common-journal/proto/TeamEvents.proto +++ b/libs/types-common-journal/proto/TeamEvents.proto @@ -13,6 +13,19 @@ message TeamEvent { required int32 member_count = 1; repeated bytes billing_user = 2; optional string currency = 3; // ISO_4217 + + // the following fields are at the end of the declaration + // for backwards compatibility. + // + // this declaration is used to generate producer code, so it + // is ok and desirable to make this fields mandatory (they + // are guaranteed to be present). + // + // for backwards compatibility, clients should make these + // fields optional, and fall back to using `member_count` if + // they are missing. + required int32 member_count_regular = 4; + required int32 member_count_app = 5; } enum EventType { diff --git a/libs/wire-api/src/Wire/API/Team/Size.hs b/libs/wire-api/src/Wire/API/Team/Size.hs index 65f6c23b0d7..d751769a903 100644 --- a/libs/wire-api/src/Wire/API/Team/Size.hs +++ b/libs/wire-api/src/Wire/API/Team/Size.hs @@ -16,30 +16,67 @@ -- with this program. If not, see . module Wire.API.Team.Size - ( TeamSize (TeamSize), + ( TeamSize (..), + teamSizeTotal, + updateTeamSize, ) where import Control.Lens ((?~)) import Data.Aeson qualified as A +import Data.Aeson.Types qualified as A import Data.OpenApi qualified as S import Data.Schema import Imports import Numeric.Natural import Test.QuickCheck (arbitrarySizedNatural) +import Wire.API.User.Search import Wire.Arbitrary -newtype TeamSize = TeamSize Natural +data TeamSize = TeamSize + { regulars :: Natural, + apps :: Natural + } deriving (Show, Eq) deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema TeamSize) +-- | Total team members (regulars + apps). +teamSizeTotal :: TeamSize -> Natural +teamSizeTotal ts = ts.regulars + ts.apps + +-- Increase or decrease a team size component, depending on user type. + +-- If the result of a decrease is <0, it is set to 1 (regulars) or 0 +-- (apps). This handles corner cases where ES reports lower numbers +-- from the past. +updateTeamSize :: UserTypeFilter -> TeamSize -> Int -> TeamSize +updateTeamSize = go + where + go :: UserTypeFilter -> TeamSize -> Int -> TeamSize + go UserTypeFilterRegular (TeamSize rs as) n = TeamSize (upd 1 rs n) as + go UserTypeFilterApp (TeamSize rs as) n = TeamSize rs (upd 0 as n) + + upd :: Int -> Natural -> Int -> Natural + upd low n i = fromIntegral . max low $ fromIntegral n + i + instance ToSchema TeamSize where schema = - objectWithDocModifier (description ?~ "A simple object with a total number of team members.") $ - TeamSize <$> (unTeamSize .= fieldWithDocModifier "teamSize" (description ?~ "Team size.") schema) + objectWithDocModifier (description ?~ "Team member counts broken down by user type.") $ + fromTeamSize .= tripleSchema `withParser` validate where - unTeamSize :: TeamSize -> Natural - unTeamSize (TeamSize n) = n + fromTeamSize :: TeamSize -> (Natural, Natural, Maybe Natural) + fromTeamSize ts = (ts.regulars, ts.apps, Just (teamSizeTotal ts)) + tripleSchema :: ObjectSchema SwaggerDoc (Natural, Natural, Maybe Natural) + tripleSchema = + (,,) + <$> (\(r, _, _) -> r) .= fieldWithDocModifier "teamSizeRegulars" (description ?~ "Number of regular users in team.") schema + <*> (\(_, a, _) -> a) .= fieldWithDocModifier "teamSizeApps" (description ?~ "Number of apps in team.") schema + <*> (\(_, _, t) -> t) .= maybe_ (optFieldWithDocModifier "teamSize" (description ?~ "Total team members (teamSizeRegulars + teamSizeApps).") schema) + validate :: (Natural, Natural, Maybe Natural) -> A.Parser TeamSize + validate (r, a, Nothing) = pure TeamSize {regulars = r, apps = a} + validate (r, a, Just t) + | r + a == t = pure TeamSize {regulars = r, apps = a} + | otherwise = fail $ "teamSize (" <> show t <> ") != regulars + apps (" <> show (r + a) <> ")" instance Arbitrary TeamSize where - arbitrary = TeamSize <$> arbitrarySizedNatural + arbitrary = TeamSize <$> arbitrarySizedNatural <*> arbitrarySizedNatural diff --git a/libs/wire-api/src/Wire/API/User/Search.hs b/libs/wire-api/src/Wire/API/User/Search.hs index 07a5c27cd29..13325e91915 100644 --- a/libs/wire-api/src/Wire/API/User/Search.hs +++ b/libs/wire-api/src/Wire/API/User/Search.hs @@ -317,6 +317,9 @@ instance FromByteString RoleFilter where parts <- C8.split ',' <$> parser RoleFilter <$> traverse (maybe (fail "Invalid role") pure . fromByteString) parts +-- In some places, we don't have bots as an option, so we don't want +-- to use 'UserType'. Once bots are removed from the picture, +-- 'UserType' and 'UserTypeFilter' will be the same ething. data UserTypeFilter = UserTypeFilterRegular | UserTypeFilterApp deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserTypeFilter) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs index 8686e290a49..8137fe05501 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs @@ -21,10 +21,10 @@ import Imports import Wire.API.Team.Size testObject_TeamSize_1 :: TeamSize -testObject_TeamSize_1 = TeamSize 0 +testObject_TeamSize_1 = TeamSize 0 0 testObject_TeamSize_2 :: TeamSize -testObject_TeamSize_2 = TeamSize 100 +testObject_TeamSize_2 = TeamSize 100 400 testObject_TeamSize_3 :: TeamSize -testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) +testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) diff --git a/libs/wire-api/test/golden/testObject_TeamSize_1.json b/libs/wire-api/test/golden/testObject_TeamSize_1.json index c883020b742..92dda71f2da 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_1.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_1.json @@ -1,3 +1,5 @@ { - "teamSize": 0 + "teamSize": 0, + "teamSizeApps": 0, + "teamSizeRegulars": 0 } diff --git a/libs/wire-api/test/golden/testObject_TeamSize_2.json b/libs/wire-api/test/golden/testObject_TeamSize_2.json index b33bf1f3bd8..5b9794591db 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_2.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_2.json @@ -1,3 +1,5 @@ { - "teamSize": 100 + "teamSize": 500, + "teamSizeApps": 400, + "teamSizeRegulars": 100 } diff --git a/libs/wire-api/test/golden/testObject_TeamSize_3.json b/libs/wire-api/test/golden/testObject_TeamSize_3.json index 2e47b19f3e7..421801b4b47 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_3.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_3.json @@ -1,3 +1,5 @@ { - "teamSize": 1.8446744073709551615e19 + "teamSize": 3.689348814741910323e19, + "teamSizeApps": 1.8446744073709551615e19, + "teamSizeRegulars": 1.8446744073709551615e19 } diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index a4a9a903052..156f8f6e479 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -23,11 +23,13 @@ import Control.Error (lastMay) import Control.Exception (throwIO) import Data.Aeson import Data.Aeson.Key qualified as Key +import Data.Aeson.Types (parseMaybe) import Data.ByteString qualified as LBS import Data.ByteString.Builder import Data.ByteString.Conversion import Data.Id import Data.List.NonEmpty (NonEmpty (..)) +import Data.Map.Strict qualified as M import Data.Text qualified as Text import Data.Text.Ascii import Data.Text.Encoding qualified as Text @@ -35,6 +37,7 @@ import Database.Bloodhound qualified as ES import Imports import Network.HTTP.Client import Network.HTTP.Types +import Numeric.Natural (Natural) import Polysemy import Wire.API.Team.Role (roleName) import Wire.API.Team.Size (TeamSize (TeamSize)) @@ -81,18 +84,49 @@ getTeamSizeImpl :: TeamId -> Sem r TeamSize getTeamSizeImpl cfg tid = do - let indexName = cfg.conn.indexName - countResEither <- embed $ ES.runBH cfg.conn.env $ ES.countByIndex indexName (ES.CountQuery query) - countRes <- either (liftIO . throwIO . IndexLookupError) pure countResEither - pure . TeamSize $ ES.crCount countRes + r <- embed $ ES.runBH cfg.conn.env $ do + res <- ES.searchByType cfg.conn.indexName mappingName search + liftIO $ ES.parseEsResponse res + result <- either (embed . throwIO . IndexLookupError) pure (r :: Either ES.EsError (ES.SearchResult UserDoc)) + let aggs = fromMaybe mempty (ES.aggregations result) + getCount name = maybe 0 (.filterDocCount) $ M.lookup name aggs >>= parseMaybe (parseJSON @FilterResult) + pure $ TeamSize (getCount "regulars") (getCount "apps") where - query = - ES.TermQuery - ES.Term - { ES.termField = "team", - ES.termValue = idToText tid + teamQ = termQ "team" (idToText tid) + + -- Regular users: type = "regular" or type field absent (legacy documents) + regularQuery = + ES.QueryBoolQuery + boolQuery + { ES.boolQueryMustMatch = + [ teamQ, + ES.QueryBoolQuery + boolQuery + { ES.boolQueryShouldMatch = + [ termQ "type" "regular", + ES.QueryBoolQuery + boolQuery + { ES.boolQueryMustNotMatch = [ES.QueryExistsQuery (ES.FieldName "type")] + } + ] + } + ] } - Nothing + + appQuery = + ES.QueryBoolQuery + boolQuery + { ES.boolQueryMustMatch = [teamQ, termQ "type" "app"] + } + + search = + (ES.mkSearch Nothing Nothing) + { ES.size = ES.Size 0, + ES.aggBody = + Just $ + ES.mkAggregations "regulars" (ES.FilterAgg (ES.FilterAggregation (ES.Filter regularQuery) Nothing)) + <> ES.mkAggregations "apps" (ES.FilterAgg (ES.FilterAggregation (ES.Filter appQuery) Nothing)) + } upsertImpl :: forall r. @@ -647,3 +681,9 @@ mappingName = ES.MappingName "user" boolQuery :: ES.BoolQuery boolQuery = ES.mkBoolQuery [] [] [] [] + +-- | (or can something like this be found in bloodhound?) +newtype FilterResult = FilterResult {filterDocCount :: Natural} + +instance FromJSON FilterResult where + parseJSON = withObject "FilterResult" $ \o -> FilterResult <$> o .: "doc_count" diff --git a/libs/wire-subsystems/src/Wire/TeamJournal.hs b/libs/wire-subsystems/src/Wire/TeamJournal.hs index c8e6ba64a00..9ae5ec1044a 100644 --- a/libs/wire-subsystems/src/Wire/TeamJournal.hs +++ b/libs/wire-subsystems/src/Wire/TeamJournal.hs @@ -27,11 +27,11 @@ import Data.ProtoLens (defMessage) import Data.Text (pack) import Data.Time.Clock.POSIX import Imports hiding (head) -import Numeric.Natural import Polysemy import Proto.TeamEvents (TeamEvent, TeamEvent'EventData, TeamEvent'EventType (..)) import Proto.TeamEvents_Fields qualified as T import Wire.API.Team (TeamCreationTime (..)) +import Wire.API.Team.Size import Wire.Sem.Now import Wire.Sem.Now qualified as Now import Wire.TeamStore @@ -52,7 +52,7 @@ teamActivate :: Member TeamJournal r ) => TeamId -> - Natural -> + TeamSize -> Maybe Currency.Alpha -> Maybe TeamCreationTime -> Sem r () @@ -65,7 +65,7 @@ teamUpdate :: Member TeamJournal r ) => TeamId -> - Natural -> + TeamSize -> [UserId] -> Sem r () teamUpdate tid teamSize billingUserIds = @@ -111,9 +111,15 @@ journalEvent typ tid dat tim = do ---------------------------------------------------------------------------- -- utils -evData :: Natural -> [UserId] -> Maybe Currency.Alpha -> TeamEvent'EventData -evData memberCount billingUserIds cur = +evData :: TeamSize -> [UserId] -> Maybe Currency.Alpha -> TeamEvent'EventData +evData teamSize@(TeamSize regulars apps) billingUserIds cur = defMessage - & T.memberCount .~ fromIntegral memberCount + & T.memberCount .~ memberCountTotal & T.billingUser .~ (toBytes <$> billingUserIds) & T.maybe'currency .~ (pack . show <$> cur) + & T.memberCountRegular .~ memberCountRegulars + & T.memberCountApp .~ memberCountApps + where + memberCountTotal, memberCountRegulars, memberCountApps :: Int32 + (memberCountTotal, memberCountRegulars, memberCountApps) = + (fromIntegral $ teamSizeTotal teamSize, fromIntegral regulars, fromIntegral apps) diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index ac8347ed4ed..42ec197d844 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -66,7 +66,7 @@ import Wire.API.Team.Member.Info (TeamMemberInfo (..), TeamMemberInfoList (membe import Wire.API.Team.Permission qualified as Permission import Wire.API.Team.Role (Role, defaultRole, permissionsToRole) import Wire.API.Team.SearchVisibility -import Wire.API.Team.Size (TeamSize (TeamSize)) +import Wire.API.Team.Size import Wire.API.User as User import Wire.API.User.RichInfo import Wire.API.User.Search @@ -251,7 +251,7 @@ internalFindTeamInvitationImpl (Just e) c = NotAllowed -> throwGuardFailed TeamInviteSetToNotAllowed maxSize <- maxTeamSize <$> input - (TeamSize teamSize) <- IndexedUserStore.getTeamSize tid + teamSize <- teamSizeTotal <$> IndexedUserStore.getTeamSize tid when (teamSize >= fromIntegral maxSize) $ throw UserSubsystemTooManyTeamMembers -- FUTUREWORK: The above can easily be done/tested in the intra call. diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs index a61614d1385..b77869840fe 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE RecordWildCards #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2025 Wire Swiss GmbH @@ -28,6 +30,7 @@ import Imports import Polysemy import Polysemy.State import Wire.API.Team.Size +import Wire.API.User import Wire.API.User.Search import Wire.IndexedUserStore import Wire.UserSearch.Types @@ -84,10 +87,13 @@ inMemoryIndexedUserStoreInterpreter = error "IndexedUserStore: unimplemented in memory interpreter" GetTeamSize tid -> gets $ \index -> - TeamSize - . fromIntegral - . length - $ Map.filter (\(doc, _) -> doc.udTeam == Just tid) index.docs + let regulars = help [Just UserTypeRegular, Nothing] + apps = help [Just UserTypeApp] + help allowedTypes = + fromIntegral + . length + $ Map.filter (\(doc, _) -> doc.udTeam == Just tid && doc.udType `elem` allowedTypes) index.docs + in TeamSize {..} upsertImpl :: (Member (State UserIndex) r) => ES.DocId -> UserDoc -> ES.VersionControl -> Sem r () upsertImpl docId userDoc versionControl = diff --git a/services/brig/test/integration/API/Team.hs b/services/brig/test/integration/API/Team.hs index 174eb48a4b8..66e1ead9e28 100644 --- a/services/brig/test/integration/API/Team.hs +++ b/services/brig/test/integration/API/Team.hs @@ -154,7 +154,7 @@ testTeamSize brig req = do void $ get (req tid uid) Sem r () ensureNotTooLargeToActivateLegalHold tid = do - (TeamSize teamSize) <- getSize tid - unlessM (teamSizeBelowLimit (fromIntegral teamSize)) $ + teamSize <- getSize tid + unlessM (teamSizeBelowLimit teamSize) $ throwS @'CannotEnableLegalHoldServiceLargeTeam teamSizeBelowLimit :: ( Member (Input FanoutLimit) r, Member (Input (FeatureDefaults LegalholdConfig)) r ) => - Int -> + TeamSize -> Sem r Bool -teamSizeBelowLimit teamSize = do - limit <- fromIntegral . fromRange <$> input @FanoutLimit +teamSizeBelowLimit (fromIntegral . teamSizeTotal -> teamSize) = do + limit :: Int <- fromIntegral . fromRange <$> input @FanoutLimit let withinLimit = teamSize <= limit featureLegalHold <- input @(FeatureDefaults LegalholdConfig) case featureLegalHold of diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 726a4651258..ee41c15cdaa 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -115,6 +115,7 @@ import Wire.API.Team.SearchVisibility import Wire.API.Team.SearchVisibility qualified as Public import Wire.API.Team.Size import Wire.API.User qualified as U +import Wire.API.User.Search import Wire.BrigAPIAccess import Wire.BrigAPIAccess qualified as Brig import Wire.BrigAPIAccess qualified as E @@ -281,12 +282,12 @@ updateTeamStatus tid (TeamStatusUpdate newStatus cur) = do -- When teams are created, they are activated immediately. In this situation, Brig will -- most likely report team size as 0 due to ES taking some time to index the team creator. -- This is also very difficult to test, so is not tested. - (TeamSize possiblyStaleSize) <- E.getSize tid - let size = - if possiblyStaleSize == 0 - then 1 - else possiblyStaleSize - Journal.teamActivate tid size c teamCreationTime + -- We could also write `updateTeamSize 1 size 0` here, but it seems clearer to do it + -- inline. + teamSize <- do + (TeamSize numRegulars numApps) <- E.getSize tid + pure $ TeamSize (max 1 numRegulars) numApps + Journal.teamActivate tid teamSize c teamCreationTime runJournal _ _ = throwS @'InvalidTeamStatusUpdate validateTransition :: (Member (ErrorS 'InvalidTeamStatusUpdate) r) => (TeamStatus, TeamStatus) -> Sem r Bool validateTransition = \case @@ -554,8 +555,6 @@ addTeamMember lzusr zcon tid nmem = do ensureNonBindingTeam tid ensureUnboundUsers [uid] E.ensureConnectedToLocals zusr [uid] - (TeamSize sizeBeforeJoin) <- E.getSize tid - ensureNotTooLargeForLegalHold tid (fromIntegral sizeBeforeJoin + 1) void $ addTeamMemberInternal tid (Just zusr) (Just zcon) nmem -- This function is "unchecked" because there is no need to check for user binding (invite only). @@ -581,11 +580,9 @@ uncheckedAddTeamMember :: NewTeamMember -> Sem r () uncheckedAddTeamMember tid nmem = do - (TeamSize sizeBeforeJoin) <- E.getSize tid - ensureNotTooLargeForLegalHold tid (fromIntegral sizeBeforeJoin + 1) - (TeamSize sizeBeforeAdd) <- addTeamMemberInternal tid Nothing Nothing nmem + newTeamSize <- addTeamMemberInternal tid Nothing Nothing nmem owners <- E.getBillingTeamMembers tid - Journal.teamUpdate tid (sizeBeforeAdd + 1) owners + Journal.teamUpdate tid newTeamSize owners uncheckedUpdateTeamMember :: forall r. @@ -627,9 +624,9 @@ uncheckedUpdateTeamMember mlzusr mZcon tid newMem = do E.setTeamMemberPermissions (previousMember ^. permissions) tid targetId targetPermissions when (team ^. teamBinding == Binding) $ do - (TeamSize size) <- E.getSize tid + teamSize <- E.getSize tid owners <- E.getBillingTeamMembers tid - Journal.teamUpdate tid size owners + Journal.teamUpdate tid teamSize owners now <- Now.get let event = newEvent tid now (EdMemberUpdate targetId (Just targetPermissions)) @@ -794,18 +791,19 @@ deleteTeamMember' lusr zcon tid remove mBody = do then do body <- mBody & note (InvalidPayload "missing request body") ensureReAuthorised (tUnqualified lusr) (body ^. tmdAuthPassword) Nothing Nothing - (TeamSize sizeBeforeDelete) <- E.getSize tid - -- TeamSize is 'Natural' and subtracting from 0 is an error - -- TeamSize could be reported as 0 if team members are added and removed very quickly, - -- which happens in tests - let sizeAfterDelete = - if sizeBeforeDelete == 0 - then 0 - else sizeBeforeDelete - 1 + uType <- + E.getUser remove <&> \case + Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp + _ -> UserTypeFilterRegular + teamSizeAfterDelete <- do + before <- E.getSize tid + pure $ updateTeamSize uType before (-1) E.deleteUser remove - E.deleteApp tid remove + case uType of + UserTypeFilterRegular -> pure () + UserTypeFilterApp -> E.deleteApp tid remove owners <- E.getBillingTeamMembers tid - Journal.teamUpdate tid sizeAfterDelete $ filter (/= remove) owners + Journal.teamUpdate tid teamSizeAfterDelete $ filter (/= remove) owners pure TeamMemberDeleteAccepted else do (feat :: LockableFeature LimitedEventFanoutConfig) <- getFeatureForTeam tid @@ -1070,20 +1068,6 @@ ensureNotElevated targetPermissions member = ) $ throwS @'InvalidPermissions -ensureNotTooLarge :: - ( Member E.BrigAPIAccess r, - Member (ErrorS 'TooManyTeamMembers) r, - Member (Input Opts) r - ) => - TeamId -> - Sem r TeamSize -ensureNotTooLarge tid = do - o <- input - (TeamSize size) <- E.getSize tid - unless (size < fromIntegral (o ^. settings . maxTeamSize)) $ - throwS @'TooManyTeamMembers - pure $ TeamSize size - -- | Ensure that a team doesn't exceed the member count limit for the LegalHold -- feature. A team with more members than the fanout limit is too large, because -- the fanout limit would prevent turning LegalHold feature _off_ again (for @@ -1102,7 +1086,7 @@ ensureNotTooLargeForLegalHold :: Member FeaturesConfigSubsystem r ) => TeamId -> - Int -> + TeamSize -> Sem r () ensureNotTooLargeForLegalHold tid teamSize = whenM (isLegalHoldEnabledForTeam tid) $ @@ -1113,12 +1097,17 @@ addTeamMemberInternal :: ( Member E.BrigAPIAccess r, Member (ErrorS 'TooManyTeamMembers) r, Member (ErrorS 'TooManyTeamAdmins) r, + Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r, Member NotificationSubsystem r, Member (Input Opts) r, Member Now r, + Member LegalHoldStore r, Member TeamNotificationStore r, Member TeamStore r, - Member P.TinyLog r + Member P.TinyLog r, + Member (Input FanoutLimit) r, + Member (Input (FeatureDefaults LegalholdConfig)) r, + Member FeaturesConfigSubsystem r ) => TeamId -> Maybe UserId -> @@ -1129,7 +1118,14 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do P.debug $ Log.field "targets" (toByteString (new ^. userId)) . Log.field "action" (Log.val "Teams.addTeamMemberInternal") - sizeBeforeAdd <- ensureNotTooLarge tid + sizeAfterAdd <- do + n <- ensureNotTooLarge tid + uType <- + E.getUser (new ^. userId) <&> \case + Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp + _ -> UserTypeFilterRegular + pure $ updateTeamSize uType n 1 + ensureNotTooLargeForLegalHold tid sizeAfterAdd admins <- E.getTeamAdmins tid let admins' = [new ^. userId | isAdminOrOwner (new ^. M.permissions)] <> admins @@ -1154,7 +1150,21 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do ] APITeamQueue.pushTeamEvent tid e - pure sizeBeforeAdd + pure sizeAfterAdd + where + ensureNotTooLarge :: + ( Member E.BrigAPIAccess r, + Member (ErrorS 'TooManyTeamMembers) r, + Member (Input Opts) r + ) => + TeamId -> + Sem r TeamSize + ensureNotTooLarge teamid = do + o <- input + teamSize <- E.getSize teamid + unless (teamSizeTotal teamSize < fromIntegral (o ^. settings . maxTeamSize)) $ + throwS @'TooManyTeamMembers + pure teamSize getBindingTeamMembers :: ( Member (ErrorS 'TeamNotFound) r, @@ -1195,8 +1205,15 @@ canUserJoinTeam :: canUserJoinTeam tid = do lhEnabled <- isLegalHoldEnabledForTeam tid when lhEnabled $ do - (TeamSize sizeBeforeJoin) <- E.getSize tid - ensureNotTooLargeForLegalHold tid (fromIntegral sizeBeforeJoin + 1) + sizeBeforeJoin <- E.getSize tid + let uType = + -- We do not have a `UserId` to check here. Also, + -- `canUserJoinTeam` is called by Brig during user + -- registration via invitation (POST /register), where apps + -- never go. So it is safe to assume "regular" + UserTypeFilterRegular + let sizeAfterJoin = updateTeamSize uType sizeBeforeJoin 1 + ensureNotTooLargeForLegalHold tid sizeAfterJoin -- | Modify and get visibility type for a team (internal, no user permission checks) getSearchVisibilityInternal ::