Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Do not count apps and collaborators as members in get-team-size. New schema: `{"teamSize": num, "apps": num, "collaborators": num}` (non-overlapping). `teamSize` has been the label since the dawn of time, only the other two have changed. The protobuf schema for TeamEvents changed accordingly.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Treat team collaborators like team members in contact search.
7 changes: 7 additions & 0 deletions integration/test/Test/Apps.hs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ testCreateGetApp sameOrOtherDomain = do
void $ assertNoEvent 5 wsRegularMember
pure (appId, cookie)

-- team size counts apps separately. (they are not members.)
bindResponse (getTeamSize owner tid) $ \resp -> do
resp.status `shouldMatchInt` 200
resp.json %. "teamSize" `shouldMatchInt` 2
resp.json %. "apps" `shouldMatchInt` 1
resp.json %. "collaborators" `shouldMatchInt` 0

-- Verify that the team.member-join event is in the team notifications queue
bindResponse (getTeamNotifications regularMember (Just lastTeamNotif)) $ \resp -> do
resp.status `shouldMatchInt` 200
Expand Down
113 changes: 113 additions & 0 deletions integration/test/Test/TeamCollaborators.hs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{-# OPTIONS_GHC -Wno-ambiguous-fields #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2025 Wire Swiss GmbH <opensource@wire.com>
Expand All @@ -17,6 +19,9 @@

module Test.TeamCollaborators where

import API.Brig as BrigP
import qualified API.BrigInternal as BrigI
import API.Common (randomName)
import API.Galley
import qualified API.GalleyInternal as Internal
import Data.Tuple.Extra
Expand Down Expand Up @@ -58,6 +63,13 @@ testCreateTeamCollaborator = do
res %. "team" `shouldMatch` team
res %. "permissions" `shouldMatch` ["create_team_conversation", "implicit_connection"]

-- team size counts collaborators separately
bindResponse (getTeamSize owner team) $ \resp -> do
resp.status `shouldMatchInt` 200
resp.json %. "teamSize" `shouldMatchInt` 2
resp.json %. "apps" `shouldMatchInt` 0
resp.json %. "collaborators" `shouldMatchInt` 1

testTeamCollaboratorEndpointsForbiddenForOtherTeams :: (HasCallStack) => App ()
testTeamCollaboratorEndpointsForbiddenForOtherTeams = do
(owner, _team, _members) <- createTeam OwnDomain 2
Expand Down Expand Up @@ -317,3 +329,104 @@ testUpdateCollaborator = do
[]
>>= assertSuccess
postOne2OneConversation bob alice team "chit-chat" >>= assertLabel 403 "operation-denied"

-- | Collaborators are part of the search space of the team they
-- collaborate with: `GET /search/contacts` returns them to members of
-- that team, just like it returns the team's own members. We test
-- collaborators from other teams, personal user accounts that
-- collaborate, and app.
testSearchFindsCollaborator :: (HasCallStack) => App ()
testSearchFindsCollaborator = do
(owner, team, [alice]) <- createTeam OwnDomain 2
(otherOwner, otherTeam, [bob, collab1]) <- createTeam OwnDomain 3
collab2 :: Value <- randomUser OwnDomain def
collab3 :: Value <-
BrigP.createApp otherOwner otherTeam def
`bindResponse` \resp -> resp.json %. "user"

collab1Name <- collab1 %. "name" & asString
collab2Name <- collab2 %. "name" & asString
collab3Name <- collab3 %. "name" & asString

collab1Name' <- randomName
collab2Name' <- randomName
collab3Name' <- randomName

-- Find before any collaborations have been established.
let assertFinds ::
(HasCallStack, MakesValue expectFound, MakesValue searcher) =>
String ->
expectFound ->
searcher ->
App ()
assertFinds searchTerm expectFound searcher = do
BrigI.refreshIndex OwnDomain
BrigP.searchContacts searcher searchTerm OwnDomain `bindResponse` \resp -> do
resp.status `shouldMatchInt` 200
foundIds :: [String] <- resp.json %. "documents" >>= asList >>= mapM objId
expectedIds :: [String] <- (make >=> asList >=> mapM objId) expectFound
assertBool
("found: " <> show foundIds <> "; expected: " <> show expectedIds)
(sort foundIds == sort expectedIds)

for_ [owner, alice] $ assertFinds collab1Name ([] @Value)
for_ [otherOwner, bob] $ assertFinds collab1Name [collab1]

for_ [owner, alice] $ assertFinds collab2Name [collab2]
for_ [otherOwner, bob] $ assertFinds collab2Name [collab2]

for_ [owner, alice] $ assertFinds collab3Name ([] @Value)
for_ [otherOwner, bob] $ assertFinds collab3Name [collab3]

-- Add collaborators to team
for_ [collab1, collab2, collab3]
$ \collab ->
addTeamCollaborator owner team collab ["implicit_connection"] >>= assertSuccess

for_ [owner, alice] $ assertFinds collab1Name [collab1]
for_ [otherOwner, bob] $ assertFinds collab1Name [collab1]

for_ [owner, alice] $ assertFinds collab2Name [collab2]
for_ [otherOwner, bob] $ assertFinds collab2Name [collab2]

for_ [owner, alice] $ assertFinds collab3Name [collab3]
for_ [otherOwner, bob] $ assertFinds collab3Name [collab3]

-- Check that updating name does not erase collaborating teams in index.
for_ [(collab1, collab1Name'), (collab2, collab2Name'), (collab3, collab3Name')]
$ \(collab, newName) -> do
let updateBody = (def :: BrigP.PutSelf) {BrigP.name = Just newName}
in BrigP.putSelf collab updateBody >>= assertSuccess

for_ [owner, alice] $ assertFinds collab1Name' [collab1]
for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1]

for_ [owner, alice] $ assertFinds collab2Name' [collab2]
for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2]

for_ [owner, alice] $ assertFinds collab3Name' [collab3]
for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3]

-- Check that updating collaborating teams does not erase name in index.
for_ [collab1, collab2, collab3]
$ \collab -> do
removeTeamCollaborator owner team collab >>= assertSuccess

for_ [owner, alice] $ assertFinds collab1Name' ([] @Value)
for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1]

for_ [owner, alice] $ assertFinds collab2Name' [collab2]
for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2]

for_ [owner, alice] $ assertFinds collab3Name' ([] @Value)
for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3]

-- Can one user collaborate in multiple teams without breaking search?
(_thirdOwner, _thirdTeam, [multiCollab]) <- createTeam OwnDomain 2
multiCollabName <- multiCollab %. "name" & asString

addTeamCollaborator owner team multiCollab ["implicit_connection"] >>= assertSuccess
addTeamCollaborator otherOwner otherTeam multiCollab ["implicit_connection"] >>= assertSuccess

for_ [owner, alice] $ assertFinds multiCollabName [multiCollab]
for_ [otherOwner, bob] $ assertFinds multiCollabName [multiCollab]
7 changes: 3 additions & 4 deletions libs/types-common-journal/proto/TeamEvents.proto
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@ message TeamEvent {
// 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;
// fields optional, and assume '0' if missing.
required int32 apps = 4;
required int32 collaborators = 5;
Comment thread
fisx marked this conversation as resolved.
}

enum EventType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ type TeamMemberAPI =
"add-team-collaborator"
( Summary "Add a collaborator to the team."
:> From 'V10
:> CanThrow 'TooManyTeamMembersOnTeamWithLegalhold
:> ZLocalUser
:> "teams"
:> Capture "tid" TeamId
Expand Down
51 changes: 9 additions & 42 deletions libs/wire-api/src/Wire/API/Team/Size.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,66 +17,33 @@

module Wire.API.Team.Size
( 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

data TeamSize = TeamSize
{ regulars :: Natural,
apps :: Natural
{ teamSize :: Natural,
apps :: Natural,
collaborators :: 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 ?~ "Team member counts broken down by user type.") $
fromTeamSize .= tripleSchema `withParser` validate
where
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) <> ")"
objectWithDocModifier (description ?~ "Team member counts: paid seats (regular users), apps, and collaborators.") $
TeamSize
<$> (.teamSize) .= field "teamSize" schema
<*> (.apps) .= field "apps" schema
<*> (.collaborators) .= field "collaborators" schema

instance Arbitrary TeamSize where
arbitrary = TeamSize <$> arbitrarySizedNatural <*> arbitrarySizedNatural
arbitrary = TeamSize <$> arbitrarySizedNatural <*> arbitrarySizedNatural <*> arbitrarySizedNatural
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import Imports
import Wire.API.Team.Size

testObject_TeamSize_1 :: TeamSize
testObject_TeamSize_1 = TeamSize 0 0
testObject_TeamSize_1 = TeamSize 0 0 0

testObject_TeamSize_2 :: TeamSize
testObject_TeamSize_2 = TeamSize 100 400
testObject_TeamSize_2 = TeamSize 100 400 7

testObject_TeamSize_3 :: TeamSize
testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64)
testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64)
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
{
"conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e",
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"from": "a471447c-aa30-4592-81b0-dec6c1c02bca",
"qualified_conversation": {
"domain": "example.com",
Expand All @@ -13,6 +9,10 @@
"domain": "example.com",
"id": "a471447c-aa30-4592-81b0-dec6c1c02bca"
},
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"time": "2018-01-01T00:00:00.000Z",
"type": "meeting.create",
"via": "user"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
{
"conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e",
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"from": "a471447c-aa30-4592-81b0-dec6c1c02bca",
"qualified_conversation": {
"domain": "example.com",
Expand All @@ -13,6 +9,10 @@
"domain": "example.com",
"id": "a471447c-aa30-4592-81b0-dec6c1c02bca"
},
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"time": "2018-01-01T00:00:00.000Z",
"type": "meeting.delete",
"via": "user"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
{
"conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e",
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"from": "a471447c-aa30-4592-81b0-dec6c1c02bca",
"qualified_conversation": {
"domain": "example.com",
Expand All @@ -13,6 +9,10 @@
"domain": "example.com",
"id": "a471447c-aa30-4592-81b0-dec6c1c02bca"
},
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"time": "2018-01-01T00:00:00.000Z",
"type": "meeting.member-add",
"via": "user"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
{
"conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e",
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"from": "a471447c-aa30-4592-81b0-dec6c1c02bca",
"qualified_conversation": {
"domain": "example.com",
Expand All @@ -13,6 +9,10 @@
"domain": "example.com",
"id": "a471447c-aa30-4592-81b0-dec6c1c02bca"
},
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"team": "00000002-0000-0000-0000-000000000002",
"time": "2018-01-01T00:00:00.000Z",
"type": "meeting.member-add",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
{
"conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e",
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"from": "a471447c-aa30-4592-81b0-dec6c1c02bca",
"qualified_conversation": {
"domain": "example.com",
Expand All @@ -13,6 +9,10 @@
"domain": "example.com",
"id": "a471447c-aa30-4592-81b0-dec6c1c02bca"
},
"qualified_id": {
"domain": "example.com",
"id": "00000001-0000-0000-0000-000000000001"
},
"time": "2018-01-01T00:00:00.000Z",
"type": "meeting.update",
"via": "user"
Expand Down
6 changes: 3 additions & 3 deletions libs/wire-api/test/golden/testObject_TeamSize_1.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"teamSize": 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it an intended breaking change?

"teamSizeApps": 0,
"teamSizeRegulars": 0
"apps": 0,
"collaborators": 0,
"teamSize": 0
}
6 changes: 3 additions & 3 deletions libs/wire-api/test/golden/testObject_TeamSize_2.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"teamSize": 500,
"teamSizeApps": 400,
"teamSizeRegulars": 100
"apps": 400,
"collaborators": 7,
"teamSize": 100
}
6 changes: 3 additions & 3 deletions libs/wire-api/test/golden/testObject_TeamSize_3.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"teamSize": 3.689348814741910323e19,
"teamSizeApps": 1.8446744073709551615e19,
"teamSizeRegulars": 1.8446744073709551615e19
"apps": 1.8446744073709551615e19,
"collaborators": 1.8446744073709551615e19,
"teamSize": 1.8446744073709551615e19
}
Loading