From 2dea4d23800d2ea797560188c7d2e69150241e84 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 18 Sep 2025 19:24:31 +0200 Subject: [PATCH 1/6] WPB-19712: Allow team admin to update the channels to user-group association --- integration/test/API/Brig.hs | 5 ++ integration/test/Test/UserGroup.hs | 53 +++++++++++++++++++ libs/wire-api/src/Wire/API/Error/Brig.hs | 3 ++ .../src/Wire/API/Routes/Public/Brig.hs | 5 +- ...-user_group_conversations_create_table.sql | 8 +++ .../src/Wire/UserGroupStore.hs | 1 + .../src/Wire/UserGroupStore/Postgres.hs | 35 +++++++++++- .../src/Wire/UserGroupSubsystem.hs | 1 + .../Wire/UserGroupSubsystem/Interpreter.hs | 24 ++++++++- .../Wire/MockInterpreters/UserGroupStore.hs | 24 ++++++++- postgres-schema.sql | 29 +++++++++- services/brig/src/Brig/API/Public.hs | 4 +- 12 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 libs/wire-subsystems/postgres-migrations/202509190851023-user_group_conversations_create_table.sql diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index b7ef7bc465..bce2531ada 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -1061,6 +1061,11 @@ getUserGroup user gid = do req <- baseRequest user Brig Versioned $ joinHttpPath ["user-groups", gid] submit "GET" req +updateUserGroupChannels :: (MakesValue user) => user -> String -> [String] -> App Response +updateUserGroupChannels user gid convIds = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["user-groups", gid, "channels"] + submit "PUT" $ req & addJSONObject ["channels" .= convIds] + data GetUserGroupsArgs = GetUserGroupsArgs { q :: Maybe String, sortByKeys :: Maybe String, diff --git a/integration/test/Test/UserGroup.hs b/integration/test/Test/UserGroup.hs index 09423cc974..770cc948c8 100644 --- a/integration/test/Test/UserGroup.hs +++ b/integration/test/Test/UserGroup.hs @@ -390,3 +390,56 @@ testUserGroupRemovalOnDelete = do bindResponse (getUserGroup alice gid) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "members" `shouldMatch` [charlieId] + +testUserGroupUpdateChannels :: (HasCallStack) => App () +testUserGroupUpdateChannels = do + (alice, tid, [_bob]) <- createTeam OwnDomain 2 + + ug <- + createUserGroup alice (object ["name" .= "none", "members" .= (mempty :: [String])]) + >>= getJSON 200 + gid <- ug %. "id" & asString + + convId <- + postConversation alice (defProteus {team = Just tid}) + >>= getJSON 201 + >>= objConvId + updateUserGroupChannels alice gid [convId.id_] >>= assertSuccess + + -- bobId <- asString $ bob %. "id" + bindResponse (getUserGroup alice gid) $ \resp -> do + resp.status `shouldMatchInt` 200 + +-- FUTUREWORK: check the actual associated channels +-- resp.json %. "members" `shouldMatch` [bobId] + +testUserGroupUpdateChannelsNonAdmin :: (HasCallStack) => App () +testUserGroupUpdateChannelsNonAdmin = do + (alice, tid, [bob]) <- createTeam OwnDomain 2 + + ug <- + createUserGroup alice (object ["name" .= "none", "members" .= (mempty :: [String])]) + >>= getJSON 200 + gid <- ug %. "id" & asString + + convId <- + postConversation alice (defProteus {team = Just tid}) + >>= getJSON 201 + >>= objConvId + updateUserGroupChannels bob gid [convId.id_] >>= assertLabel 404 "user-group-not-found" + +testUserGroupUpdateChannelsNonExisting :: (HasCallStack) => App () +testUserGroupUpdateChannelsNonExisting = do + (alice, tid, _) <- createTeam OwnDomain 1 + (bob, _, _) <- createTeam OwnDomain 1 + + ug <- + createUserGroup alice (object ["name" .= "none", "members" .= (mempty :: [String])]) + >>= getJSON 200 + gid <- ug %. "id" & asString + + convId <- + postConversation alice (defProteus {team = Just tid}) + >>= getJSON 201 + >>= objConvId + updateUserGroupChannels bob gid [convId.id_] >>= assertLabel 404 "user-group-not-found" diff --git a/libs/wire-api/src/Wire/API/Error/Brig.hs b/libs/wire-api/src/Wire/API/Error/Brig.hs index 856e5f9520..4c11d0840e 100644 --- a/libs/wire-api/src/Wire/API/Error/Brig.hs +++ b/libs/wire-api/src/Wire/API/Error/Brig.hs @@ -118,6 +118,7 @@ data BrigError | UserGroupNotFound | UserGroupNotATeamAdmin | UserGroupMemberIsNotInTheSameTeam + | UserGroupChannelNotFound | DuplicateEntry | MLSInvalidLeafNodeSignature @@ -351,6 +352,8 @@ type instance MapError 'UserGroupNotFound = 'StaticError 404 "user-group-not-fou type instance MapError 'UserGroupNotATeamAdmin = 'StaticError 403 "user-group-write-forbidden" "Only team admins can create, update, or delete user groups." +type instance MapError 'UserGroupChannelNotFound = 'StaticError 404 "user-group-channel-not-found" "Specified Channel does not exists or does not belongs to the team" + type instance MapError 'UserGroupMemberIsNotInTheSameTeam = 'StaticError 400 "user-group-invalid" "Only team members of the same team can be added to a user group." type instance MapError 'DuplicateEntry = 'StaticError 409 "duplicate-entry" "Entry already exists" diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index b54f9db2a8..3c59836110 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -418,8 +418,11 @@ type UserGroupAPI = ) :<|> Named "update-user-group-channels" - ( Summary "[STUB] Update user group channels. Replaces the channels with the given list." + ( Summary "Replaces the channels with the given list." :> From 'V12 + :> CanThrow 'UserGroupNotFound + :> CanThrow 'UserGroupNotATeamAdmin + :> CanThrow 'UserGroupNotFound :> ZLocalUser :> "user-groups" :> Capture "gid" UserGroupId diff --git a/libs/wire-subsystems/postgres-migrations/202509190851023-user_group_conversations_create_table.sql b/libs/wire-subsystems/postgres-migrations/202509190851023-user_group_conversations_create_table.sql new file mode 100644 index 0000000000..f63e3745f0 --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/202509190851023-user_group_conversations_create_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE public.user_group_channel ( + user_group_id uuid NOT NULL, + conv_id uuid NOT NULL, + PRIMARY KEY (user_group_id, conv_id) +); + +ALTER TABLE ONLY public.user_group_channel + ADD CONSTRAINT fk_user_group_channel FOREIGN KEY (user_group_id) REFERENCES public.user_group(id) ON DELETE CASCADE; diff --git a/libs/wire-subsystems/src/Wire/UserGroupStore.hs b/libs/wire-subsystems/src/Wire/UserGroupStore.hs index 6d646e4fbb..2136b2a60a 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupStore.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupStore.hs @@ -40,5 +40,6 @@ data UserGroupStore m a where AddUser :: UserGroupId -> UserId -> UserGroupStore m () UpdateUsers :: UserGroupId -> Vector UserId -> UserGroupStore m () RemoveUser :: UserGroupId -> UserId -> UserGroupStore m () + UpdateUserGroupChannels :: UserGroupId -> Vector ConvId -> UserGroupStore m () makeSem ''UserGroupStore diff --git a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs index ed216cffd6..3249df272e 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs @@ -29,7 +29,7 @@ import Polysemy.Error (Error, throw) import Polysemy.Input import Wire.API.Pagination import Wire.API.User.Profile -import Wire.API.UserGroup +import Wire.API.UserGroup hiding (UpdateUserGroupChannels) import Wire.API.UserGroup.Pagination import Wire.UserGroupStore (PaginationState (..), UserGroupPageRequest (..), UserGroupStore (..), toSortBy) @@ -53,6 +53,7 @@ interpretUserGroupStoreToPostgres = AddUser gid uid -> addUser gid uid UpdateUsers gid uids -> updateUsers gid uids RemoveUser gid uid -> removeUser gid uid + UpdateUserGroupChannels gid convIds -> updateUserGroupChannels gid convIds updateUsers :: (UserGroupStorePostgresEffectConstraints r) => UserGroupId -> Vector UserId -> Sem r () updateUsers gid uids = do @@ -408,6 +409,38 @@ removeUser = delete from user_group_member where user_group_id = ($1 :: uuid) and user_id = ($2 :: uuid) |] +updateUserGroupChannels :: + forall r. + (UserGroupStorePostgresEffectConstraints r) => + UserGroupId -> + Vector ConvId -> + Sem r () +updateUserGroupChannels gid convIds = do + pool <- input + eitherErrorOrUnit <- liftIO $ use pool session + either throw pure eitherErrorOrUnit + where + session :: Session () + session = TxSessions.transaction TxSessions.Serializable TxSessions.Write $ do + Tx.statement (gid, convIds) deleteStatement + Tx.statement (gid, convIds) insertStatement + + deleteStatement :: Statement (UserGroupId, Vector ConvId) () + deleteStatement = + lmap + (bimap toUUID (fmap toUUID)) + $ [resultlessStatement| + delete from user_group_channel where user_group_id = ($1 :: uuid) and conv_id not in (SELECT unnest($2 :: uuid[])) + |] + + insertStatement :: Statement (UserGroupId, Vector ConvId) () + insertStatement = + lmap (bimap (fmap (.toUUID)) (fmap (.toUUID)) . uncurry toRelationTable) $ + [resultlessStatement| + insert into user_group_channel (user_group_id, conv_id) select * from unnest ($1 :: uuid[], $2 :: uuid[]) + on conflict (user_group_id, conv_id) do nothing + |] + crudUser :: forall r. (UserGroupStorePostgresEffectConstraints r) => diff --git a/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs b/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs index 100e2d3120..16df18d5b5 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupSubsystem.hs @@ -33,5 +33,6 @@ data UserGroupSubsystem m a where UpdateUsers :: UserId -> UserGroupId -> Vector UserId -> UserGroupSubsystem m () RemoveUser :: UserId -> UserGroupId -> UserId -> UserGroupSubsystem m () RemoveUserFromAllGroups :: UserId -> TeamId -> UserGroupSubsystem m () + UpdateChannels :: UserId -> UserGroupId -> Vector ConvId -> UserGroupSubsystem m () makeSem ''UserGroupSubsystem diff --git a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs index f1aa7c406f..173b8cde75 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs @@ -23,6 +23,7 @@ import Wire.API.UserEvent import Wire.API.UserGroup import Wire.API.UserGroup.Pagination import Wire.Error +import Wire.GalleyAPIAccess (GalleyAPIAccess, getTeamConv) import Wire.NotificationSubsystem import Wire.TeamSubsystem import Wire.UserGroupStore (PaginationState (..), UserGroupPageRequest (..)) @@ -36,7 +37,8 @@ interpretUserGroupSubsystem :: Member Store.UserGroupStore r, Member (Input (Local ())) r, Member NotificationSubsystem r, - Member TeamSubsystem r + Member TeamSubsystem r, + Member GalleyAPIAccess r ) => InterpreterFor UserGroupSubsystem r interpretUserGroupSubsystem = interpret $ \case @@ -51,11 +53,13 @@ interpretUserGroupSubsystem = interpret $ \case UpdateUsers updater groupId uids -> updateUsers updater groupId uids RemoveUser remover groupId removeeId -> removeUser remover groupId removeeId RemoveUserFromAllGroups uid tid -> removeUserFromAllGroups uid tid + UpdateChannels performer groupId channelIds -> updateChannels performer groupId channelIds data UserGroupSubsystemError = UserGroupNotATeamAdmin | UserGroupMemberIsNotInTheSameTeam | UserGroupNotFound + | UserGroupChannelNotFound deriving (Show, Eq) userGroupSubsystemErrorToHttpError :: UserGroupSubsystemError -> HttpError @@ -64,6 +68,7 @@ userGroupSubsystemErrorToHttpError = UserGroupNotATeamAdmin -> errorToWai @E.UserGroupNotATeamAdmin UserGroupMemberIsNotInTheSameTeam -> errorToWai @E.UserGroupMemberIsNotInTheSameTeam UserGroupNotFound -> errorToWai @E.UserGroupNotFound + UserGroupChannelNotFound -> errorToWai @E.UserGroupChannelNotFound createUserGroup :: ( Member UserSubsystem r, @@ -364,3 +369,20 @@ removeUserFromAllGroups uid tid = do searchString = Nothing, includeMemberCount = False } + +updateChannels :: + ( Member UserSubsystem r, + Member Store.UserGroupStore r, + Member (Error UserGroupSubsystemError) r, + Member TeamSubsystem r, + Member GalleyAPIAccess r + ) => + UserId -> + UserGroupId -> + Vector ConvId -> + Sem r () +updateChannels performer groupId channelIds = do + void $ getUserGroup performer groupId >>= note UserGroupNotFound + teamId <- getTeamAsAdmin performer >>= note UserGroupNotATeamAdmin + traverse_ (getTeamConv performer teamId >=> note UserGroupChannelNotFound) channelIds + Store.updateUserGroupChannels groupId channelIds diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs index 8be5da9a0e..935197ffe2 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserGroupStore.hs @@ -7,9 +7,11 @@ module Wire.MockInterpreters.UserGroupStore where import Control.Lens ((%~), _2) +import Data.Domain (Domain (Domain)) import Data.Id import Data.Json.Util import Data.Map qualified as Map +import Data.Qualified (Qualified (Qualified)) import Data.Text qualified as T import Data.Time.Clock import Data.Vector (Vector, fromList) @@ -21,7 +23,7 @@ import Polysemy.State import System.Random (StdGen, mkStdGen) import Wire.API.Pagination import Wire.API.User -import Wire.API.UserGroup +import Wire.API.UserGroup hiding (UpdateUserGroupChannels) import Wire.API.UserGroup.Pagination import Wire.MockInterpreters.Now import Wire.MockInterpreters.Random @@ -62,6 +64,7 @@ userGroupStoreTestInterpreter = AddUser gid uid -> addUserImpl gid uid UpdateUsers gid uids -> updateUsersImpl gid uids RemoveUser gid uid -> removeUserImpl gid uid + UpdateUserGroupChannels gid convIds -> updateUserGroupChannelsImpl gid convIds updateUsersImpl :: (UserGroupStoreInMemEffectConstraints r) => UserGroupId -> Vector UserId -> Sem r () updateUsersImpl gid uids = do @@ -179,6 +182,25 @@ removeUserImpl gid uid = do modifyUserGroupsGidOnly gid (Map.alter f) +updateUserGroupChannelsImpl :: + (UserGroupStoreInMemEffectConstraints r) => + UserGroupId -> + Vector ConvId -> + Sem r () +updateUserGroupChannelsImpl gid convIds = do + let f :: Maybe UserGroup -> Maybe UserGroup + f Nothing = Nothing + f (Just g) = + Just + ( g + { channels = Identity $ Just $ flip Qualified (Domain "") <$> convIds, + channelsCount = Just $ length convIds + } :: + UserGroup + ) + + modifyUserGroupsGidOnly gid (Map.alter f) + ---------------------------------------------------------------------- modifyUserGroupsGidOnly :: diff --git a/postgres-schema.sql b/postgres-schema.sql index c23af1aa97..9075fe312f 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -92,9 +92,20 @@ CREATE TABLE public.user_group_member ( user_id uuid NOT NULL ); - ALTER TABLE public.user_group_member OWNER TO "wire-server"; +-- +-- Name: user_group_channel; Type: TABLE; Schema: public; Owner: wire-server +-- + +CREATE TABLE public.user_group_channel ( + user_group_id uuid NOT NULL, + conv_id uuid NOT NULL +); + + +ALTER TABLE public.user_group_channel OWNER TO "wire-server"; + -- -- Name: collaborators collaborators_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -119,6 +130,14 @@ ALTER TABLE ONLY public.user_group_member ADD CONSTRAINT user_group_member_pkey PRIMARY KEY (user_group_id, user_id); +-- +-- Name: user_group_channel user_group_member_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.user_group_channel + ADD CONSTRAINT user_group_channel_pkey PRIMARY KEY (user_group_id, conv_id); + + -- -- Name: user_group user_group_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server -- @@ -149,6 +168,14 @@ ALTER TABLE ONLY public.user_group_member ADD CONSTRAINT fk_user_group FOREIGN KEY (user_group_id) REFERENCES public.user_group(id) ON DELETE CASCADE; +-- +-- Name: user_group_channel fk_user_group; Type: FK CONSTRAINT; Schema: public; Owner: wire-server +-- + +ALTER TABLE ONLY public.user_group_channel + ADD CONSTRAINT fk_user_group_channel FOREIGN KEY (user_group_id) REFERENCES public.user_group(id) ON DELETE CASCADE; + + -- -- Name: SCHEMA public; Type: ACL; Schema: -; Owner: wire-server -- diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 1baab70456..7ca8f6b1a0 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -1713,8 +1713,8 @@ removeUserFromGroup lusr gid mid = lift . liftSem $ UserGroup.removeUser (tUnqua updateUserGroupMembers :: (_) => Local UserId -> UserGroupId -> UpdateUserGroupMembers -> Handler r () updateUserGroupMembers lusr gid gupd = lift . liftSem $ UserGroup.updateUsers (tUnqualified lusr) gid gupd.members -updateUserGroupChannels :: Local UserId -> UserGroupId -> UpdateUserGroupChannels -> Handler r () -updateUserGroupChannels _ _ _ = pure () +updateUserGroupChannels :: (_) => Local UserId -> UserGroupId -> UpdateUserGroupChannels -> Handler r () +updateUserGroupChannels lusr gid upd = lift . liftSem $ UserGroup.updateChannels (tUnqualified lusr) gid upd.channels checkUserGroupNameAvailable :: Local UserId -> CheckUserGroupName -> Handler r UserGroupNameAvailability checkUserGroupNameAvailable _ _ = pure $ UserGroupNameAvailability True From 756198bf3bcba7e5a0d678bddbeca0b76bd9dd74 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Oct 2025 12:45:29 +0200 Subject: [PATCH 2/6] feat: check that conversations are channels --- .../src/Wire/GalleyAPIAccess.hs | 1 + .../src/Wire/GalleyAPIAccess/Rpc.hs | 24 +++++++++++++++++++ .../Wire/UserGroupSubsystem/Interpreter.hs | 15 ++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/GalleyAPIAccess.hs b/libs/wire-subsystems/src/Wire/GalleyAPIAccess.hs index c4dd5fcc26..07fd8d4a28 100644 --- a/libs/wire-subsystems/src/Wire/GalleyAPIAccess.hs +++ b/libs/wire-subsystems/src/Wire/GalleyAPIAccess.hs @@ -144,5 +144,6 @@ data GalleyAPIAccess m a where UserId -> GalleyAPIAccess m [EJPDConvInfo] GetTeamAdmins :: TeamId -> GalleyAPIAccess m Team.TeamMemberList + InternalGetConversation :: ConvId -> GalleyAPIAccess m (Maybe Conversation) makeSem ''GalleyAPIAccess diff --git a/libs/wire-subsystems/src/Wire/GalleyAPIAccess/Rpc.hs b/libs/wire-subsystems/src/Wire/GalleyAPIAccess/Rpc.hs index 2ac97e142e..2562ae6c29 100644 --- a/libs/wire-subsystems/src/Wire/GalleyAPIAccess/Rpc.hs +++ b/libs/wire-subsystems/src/Wire/GalleyAPIAccess/Rpc.hs @@ -96,6 +96,7 @@ interpretGalleyAPIAccessToRpc disabledVersions galleyEndpoint = UnblockConversation lusr mconn qcnv -> unblockConversation v lusr mconn qcnv GetEJPDConvInfo uid -> getEJPDConvInfo uid GetTeamAdmins tid -> getTeamAdmins tid + InternalGetConversation id' -> internalGetConversation id' getUserLegalholdStatus :: ( Member TinyLog r, @@ -680,3 +681,26 @@ getEJPDConvInfo uid = do getReq = method GET . paths ["i", "user", toByteString' uid, "all-conversations"] + +internalGetConversation :: + ( Member (Error ParseException) r, + Member Rpc r, + Member (Input Endpoint) r, + Member TinyLog r + ) => + ConvId -> + Sem r (Maybe Conversation) +internalGetConversation convId = do + debug $ + remote "galley" + . field "conv" (toByteString convId) + . msg (val "Getting conversation (internal)") + rs <- galleyRequest req + case Bilge.statusCode rs of + 200 -> Just <$> decodeBodyOrThrow "galley" rs + _ -> pure Nothing + where + req = + method GET + . paths ["i", "conversations", toByteString' convId] + . expect [status200, status404] diff --git a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs index 173b8cde75..5eb9936add 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs @@ -12,6 +12,7 @@ import Imports import Polysemy import Polysemy.Error import Polysemy.Input (Input, input) +import Wire.API.Conversation qualified as Conversation import Wire.API.Error import Wire.API.Error.Brig qualified as E import Wire.API.Pagination @@ -23,7 +24,7 @@ import Wire.API.UserEvent import Wire.API.UserGroup import Wire.API.UserGroup.Pagination import Wire.Error -import Wire.GalleyAPIAccess (GalleyAPIAccess, getTeamConv) +import Wire.GalleyAPIAccess (GalleyAPIAccess, internalGetConversation) import Wire.NotificationSubsystem import Wire.TeamSubsystem import Wire.UserGroupStore (PaginationState (..), UserGroupPageRequest (..)) @@ -375,6 +376,7 @@ updateChannels :: Member Store.UserGroupStore r, Member (Error UserGroupSubsystemError) r, Member TeamSubsystem r, + Member NotificationSubsystem r, Member GalleyAPIAccess r ) => UserId -> @@ -384,5 +386,14 @@ updateChannels :: updateChannels performer groupId channelIds = do void $ getUserGroup performer groupId >>= note UserGroupNotFound teamId <- getTeamAsAdmin performer >>= note UserGroupNotATeamAdmin - traverse_ (getTeamConv performer teamId >=> note UserGroupChannelNotFound) channelIds + for_ channelIds $ \channelId -> do + conv <- internalGetConversation channelId >>= note UserGroupChannelNotFound + let meta = conv.metadata + unless (meta.cnvmTeam == Just teamId && meta.cnvmGroupConvType == Just Conversation.Channel) $ + throw UserGroupChannelNotFound Store.updateUserGroupChannels groupId channelIds + + admins <- fmap (^. TM.userId) . (^. teamMembers) <$> internalGetTeamAdmins teamId + pushNotifications + [ mkEvent performer (UserGroupUpdated groupId) admins + ] From 82ed46cafb671ea6c61f0d2c3980b34911662706 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Oct 2025 13:39:33 +0200 Subject: [PATCH 3/6] fix: implement minBackend --- .../test/unit/Wire/MockInterpreters/GalleyAPIAccess.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/GalleyAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/GalleyAPIAccess.hs index a99e6ab713..a4d35656f9 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/GalleyAPIAccess.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/GalleyAPIAccess.hs @@ -49,6 +49,7 @@ miniGalleyAPIAccess teams configs = interpret $ \case GetEJPDConvInfo _ -> error "GetEJPDConvInfo not implemented in miniGalleyAPIAccess" GetTeamAdmins tid -> pure $ newTeamMemberList (maybe [] (filter (\tm -> isAdminOrOwner (tm ^. permissions))) $ Map.lookup tid teams) ListComplete SelectTeamMemberInfos tid uids -> pure $ selectTeamMemberInfosImpl teams tid uids + InternalGetConversation _ -> error "GetConv not implemented in InternalGetConversation" -- this is called but the result is not needed in unit tests selectTeamMemberInfosImpl :: Map TeamId [TeamMember] -> TeamId -> [UserId] -> TeamMemberInfoList From d1a293ea8e520fee681f1eb22fc4f836645c53c8 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Oct 2025 15:07:19 +0200 Subject: [PATCH 4/6] fix: tests and add one --- integration/test/Test/UserGroup.hs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/integration/test/Test/UserGroup.hs b/integration/test/Test/UserGroup.hs index 770cc948c8..f67698ff89 100644 --- a/integration/test/Test/UserGroup.hs +++ b/integration/test/Test/UserGroup.hs @@ -4,6 +4,7 @@ module Test.UserGroup where import API.Brig import API.Galley +import API.GalleyInternal (setTeamFeatureLockStatus) import Control.Error (lastMay) import Notifications (isUserGroupCreatedNotif, isUserGroupUpdatedNotif) import SetupHelpers @@ -394,6 +395,17 @@ testUserGroupRemovalOnDelete = do testUserGroupUpdateChannels :: (HasCallStack) => App () testUserGroupUpdateChannels = do (alice, tid, [_bob]) <- createTeam OwnDomain 2 + setTeamFeatureLockStatus alice tid "channels" "unlocked" + let config = + object + [ "status" .= "enabled", + "config" + .= object + [ "allowed_to_create_channels" .= "team-members", + "allowed_to_open_channels" .= "team-members" + ] + ] + void $ setTeamFeatureConfig alice tid "channels" config ug <- createUserGroup alice (object ["name" .= "none", "members" .= (mempty :: [String])]) @@ -401,7 +413,7 @@ testUserGroupUpdateChannels = do gid <- ug %. "id" & asString convId <- - postConversation alice (defProteus {team = Just tid}) + postConversation alice (defMLS {team = Just tid, groupConvType = Just "channel"}) >>= getJSON 201 >>= objConvId updateUserGroupChannels alice gid [convId.id_] >>= assertSuccess @@ -443,3 +455,18 @@ testUserGroupUpdateChannelsNonExisting = do >>= getJSON 201 >>= objConvId updateUserGroupChannels bob gid [convId.id_] >>= assertLabel 404 "user-group-not-found" + +testUserGroupUpdateChannelsNonChannel :: (HasCallStack) => App () +testUserGroupUpdateChannelsNonChannel = do + (alice, tid, [_bob]) <- createTeam OwnDomain 2 + + ug <- + createUserGroup alice (object ["name" .= "none", "members" .= (mempty :: [String])]) + >>= getJSON 200 + gid <- ug %. "id" & asString + + convId <- + postConversation alice (defProteus {team = Just tid}) + >>= getJSON 201 + >>= objConvId + updateUserGroupChannels alice gid [convId.id_] >>= assertLabel 404 "user-group-channel-not-found" From d495acbd85c69447cdf160c5567d1a29d0f8b948 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Oct 2025 15:16:06 +0200 Subject: [PATCH 5/6] fix: test notification --- integration/test/Test/UserGroup.hs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/integration/test/Test/UserGroup.hs b/integration/test/Test/UserGroup.hs index f67698ff89..6b3fae9674 100644 --- a/integration/test/Test/UserGroup.hs +++ b/integration/test/Test/UserGroup.hs @@ -405,7 +405,7 @@ testUserGroupUpdateChannels = do "allowed_to_open_channels" .= "team-members" ] ] - void $ setTeamFeatureConfig alice tid "channels" config + setTeamFeatureConfig alice tid "channels" config >>= assertSuccess ug <- createUserGroup alice (object ["name" .= "none", "members" .= (mempty :: [String])]) @@ -416,7 +416,11 @@ testUserGroupUpdateChannels = do postConversation alice (defMLS {team = Just tid, groupConvType = Just "channel"}) >>= getJSON 201 >>= objConvId - updateUserGroupChannels alice gid [convId.id_] >>= assertSuccess + withWebSocket alice $ \wsAlice -> do + updateUserGroupChannels alice gid [convId.id_] >>= assertSuccess + + notif <- awaitMatch isUserGroupUpdatedNotif wsAlice + notif %. "payload.0.user_group.id" `shouldMatch` gid -- bobId <- asString $ bob %. "id" bindResponse (getUserGroup alice gid) $ \resp -> do From ff99a34ba1cba00f205e288eb5bf134f288695c1 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Oct 2025 15:26:22 +0200 Subject: [PATCH 6/6] fix: docs --- changelog.d/2-features/WPB-19712 | 1 + libs/wire-api/src/Wire/API/Routes/Public/Brig.hs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/2-features/WPB-19712 diff --git a/changelog.d/2-features/WPB-19712 b/changelog.d/2-features/WPB-19712 new file mode 100644 index 0000000000..7353788a0a --- /dev/null +++ b/changelog.d/2-features/WPB-19712 @@ -0,0 +1 @@ +Implement user-groups channels association (`/user-groups/{gid}/channels`). diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 3c59836110..fc8e1ea258 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -422,7 +422,6 @@ type UserGroupAPI = :> From 'V12 :> CanThrow 'UserGroupNotFound :> CanThrow 'UserGroupNotATeamAdmin - :> CanThrow 'UserGroupNotFound :> ZLocalUser :> "user-groups" :> Capture "gid" UserGroupId