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 ::