From fb4756756d120bea46622ccc60943be7b4ff1ab3 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 9 Jul 2026 18:09:49 +0200 Subject: [PATCH 01/10] WPB-26705: add meeting.create/update/delete lifecycle events Add meeting lifecycle websocket notifications (meeting.create, meeting.update, meeting.delete) emitted on every successful create, update, and delete operation. Events are pushed to all local members of the meeting's conversation via NotificationSubsystem. - New Wire.API.Event.Meeting type with EventType enum and Event record serializing to {type, time, qualified_id} - Golden tests for the new Event type - MeetingsSubsystem.Interpreter emits events via pushMeetingEvent helper - Unit tests verify event emission (and absence on failed operations) - Integration tests await meeting.* notifications on create/update/delete --- .../2-features/wpb-26705-meeting-events.md | 5 + integration/test/Notifications.hs | 12 ++ integration/test/Test/Meetings.hs | 14 ++- libs/wire-api/src/Wire/API/Event/Meeting.hs | 87 ++++++++++++++ .../golden/Test/Wire/API/Golden/Generated.hs | 10 ++ .../API/Golden/Generated/Event_meeting.hs | 88 ++++++++++++++ .../golden/testObject_Event_meeting_1.json | 8 ++ .../golden/testObject_Event_meeting_2.json | 8 ++ .../golden/testObject_Event_meeting_3.json | 8 ++ .../golden/testObject_Event_meeting_4.json | 8 ++ .../golden/testObject_Event_meeting_5.json | 8 ++ .../golden/testObject_Event_meeting_6.json | 8 ++ libs/wire-api/wire-api.cabal | 2 + .../src/Wire/MeetingsSubsystem/Interpreter.hs | 40 +++++++ .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 108 ++++++++++++++++++ 15 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2-features/wpb-26705-meeting-events.md create mode 100644 libs/wire-api/src/Wire/API/Event/Meeting.hs create mode 100644 libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_1.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_2.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_3.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_4.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_5.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_6.json diff --git a/changelog.d/2-features/wpb-26705-meeting-events.md b/changelog.d/2-features/wpb-26705-meeting-events.md new file mode 100644 index 00000000000..782f2cd7e38 --- /dev/null +++ b/changelog.d/2-features/wpb-26705-meeting-events.md @@ -0,0 +1,5 @@ +* Added meeting lifecycle events: `meeting.create`, `meeting.update`, and + `meeting.delete` (WPB-26705). These websocket notifications are pushed to all + local members of the meeting's conversation on every successful create, update, + and delete operation. Each event payload contains the event `type`, `time`, and + the `qualified_id` of the affected meeting. diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 67281da2d6e..9b1aa274a0f 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -196,6 +196,18 @@ isConvCreateMeetingNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateMeetingNotif n = fieldEquals n "payload.0.type" "conversation.create-meeting" +isMeetingCreateNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingCreateNotif n = + fieldEquals n "payload.0.type" "meeting.create" + +isMeetingUpdateNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingUpdateNotif n = + fieldEquals n "payload.0.type" "meeting.update" + +isMeetingDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingDeleteNotif n = + fieldEquals n "payload.0.type" "meeting.delete" + -- | like 'isConvCreateNotif' but excludes self conversations isConvCreateNotifNotSelf :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateNotifNotSelf n = diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 5355471b5cf..8b237f42a6f 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -10,7 +10,7 @@ import qualified Data.Text.Encoding as Text import Data.Time.Clock import qualified Data.Time.Format as Time import MLS.Util -import Notifications (isConvCreateMeetingNotif) +import Notifications (isConvCreateMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingUpdateNotif) import SetupHelpers import System.Timeout (timeout) import Testlib.Prelude @@ -31,6 +31,7 @@ testMeetingCreate = do resp <- postMeetings owner newMeeting assertSuccess resp void $ awaitMatch isConvCreateMeetingNotif ws + void $ awaitMatch isMeetingCreateNotif ws getJSON 201 resp meeting %. "title" `shouldMatch` ("Team Standup" :: String) @@ -202,8 +203,11 @@ testMeetingRecurrence = do "recurrence" .= updatedRecurrence ] - r2 <- putMeeting owner domain meetingId updatedMeeting - assertSuccess r2 + r2 <- withWebSocket owner $ \ws -> do + resp <- putMeeting owner domain meetingId updatedMeeting + assertSuccess resp + void $ awaitMatch isMeetingUpdateNotif ws + pure resp updated <- getJSON 200 r2 updated %. "title" `shouldMatch` ("Updated Standup" :: String) @@ -443,7 +447,9 @@ testMeetingDelete = do assertSuccess r1 meeting <- getJSON 201 r1 (meetingId, domain) <- getMeetingIdAndDomain meeting - deleteMeeting owner domain meetingId >>= assertStatus 200 + withWebSocket owner $ \ws -> do + deleteMeeting owner domain meetingId >>= assertStatus 200 + void $ awaitMatch isMeetingDeleteNotif ws getMeeting owner domain meetingId >>= assertStatus 404 testMeetingDeleteNotFound :: (HasCallStack) => App () diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs new file mode 100644 index 00000000000..9be05e2a248 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Event/Meeting.hs @@ -0,0 +1,87 @@ +{-# LANGUAGE StrictData #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Event.Meeting + ( -- * Event + Event (..), + newEvent, + eventType, + + -- * EventType + EventType (..), + ) +where + +import Data.Aeson (FromJSON, ToJSON (toJSON), Value (Object)) +import Data.Id (MeetingId) +import Data.Json.Util (ToJSONObject (toJSONObject), utcTimeSchema) +import Data.OpenApi qualified as S +import Data.Qualified (Qualified) +import Data.Schema +import Data.Time.Clock (UTCTime) +import Imports +import Wire.Arbitrary (Arbitrary, GenericUniform (..)) + +-------------------------------------------------------------------------------- +-- EventType + +data EventType = MeetingCreate | MeetingUpdate | MeetingDelete + deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) + deriving (Arbitrary) via (GenericUniform EventType) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType + +instance ToSchema EventType where + schema = + enum @Text $ + mconcat + [ element "meeting.create" MeetingCreate, + element "meeting.update" MeetingUpdate, + element "meeting.delete" MeetingDelete + ] + +-------------------------------------------------------------------------------- +-- Event + +data Event = Event + { evtType :: EventType, + evtTime :: UTCTime, + evtQualifiedId :: Qualified MeetingId + } + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON, S.ToSchema) via Schema Event + deriving (Arbitrary) via (GenericUniform Event) + +instance ToSchema Event where + schema = + object $ + Event + <$> (.evtType) .= field "type" schema + <*> (.evtTime) .= field "time" utcTimeSchema + <*> (.evtQualifiedId) .= field "qualified_id" schema + +instance ToJSONObject Event where + toJSONObject e = case toJSON e of + Object o -> o + _ -> error "Wire.API.Event.Meeting.Event.toJSON did not produce an Object" + +newEvent :: UTCTime -> EventType -> Qualified MeetingId -> Event +newEvent time ty qid = Event ty time qid + +eventType :: Event -> EventType +eventType = (.evtType) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs index 02dc1d21e43..916894af355 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs @@ -83,6 +83,7 @@ import Test.Wire.API.Golden.Generated.EventType_team qualified import Test.Wire.API.Golden.Generated.EventType_user qualified import Test.Wire.API.Golden.Generated.Event_conversation qualified import Test.Wire.API.Golden.Generated.Event_featureConfig qualified +import Test.Wire.API.Golden.Generated.Event_meeting qualified import Test.Wire.API.Golden.Generated.Event_team qualified import Test.Wire.API.Golden.Generated.Event_user qualified import Test.Wire.API.Golden.Generated.Feature_team qualified @@ -1498,6 +1499,15 @@ tests = (Test.Wire.API.Golden.Generated.Event_conversation.testObject_Event_conversation_11, "testObject_Event_conversation_11.json"), (Test.Wire.API.Golden.Generated.Event_conversation.testObject_Event_conversation_10, "testObject_Event_conversation_10.json") ], + testGroup "Golden: Event_meeting" $ + testObjects + [ (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_1, "testObject_Event_meeting_1.json"), + (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_2, "testObject_Event_meeting_2.json"), + (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_3, "testObject_Event_meeting_3.json"), + (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_4, "testObject_Event_meeting_4.json"), + (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_5, "testObject_Event_meeting_5.json"), + (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_6, "testObject_Event_meeting_6.json") + ], testGroup "Golden: QualifiedUserClientPrekeyMapV4" $ testObjects [ (Test.Wire.API.Golden.Generated.QualifiedUserClientPrekeyMapV4_user.testObject_QualifiedUserClientPrekeyMapV4_user_1, "testObject_QualifiedUserClientPrekeyMapV4_1.json"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs new file mode 100644 index 00000000000..c207a6bcf44 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE OverloadedLists #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Generated.Event_meeting where + +import Data.Domain (Domain (..)) +import Data.Id (Id (Id)) +import Data.Qualified (Qualified (..)) +import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime) +import Data.UUID qualified as UUID +import Imports (fromJust) +import Wire.API.Event.Meeting + +testObject_Event_meeting_1 :: Event +testObject_Event_meeting_1 = + newEvent + (UTCTime (fromGregorian 2026 1 1) (secondsToDiffTime 0)) + MeetingCreate + ( Qualified + (Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001"))) + (Domain "example.com") + ) + +testObject_Event_meeting_2 :: Event +testObject_Event_meeting_2 = + newEvent + (UTCTime (fromGregorian 2026 2 3) (secondsToDiffTime 37215)) + MeetingUpdate + ( Qualified + (Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002"))) + (Domain "example.com") + ) + +testObject_Event_meeting_3 :: Event +testObject_Event_meeting_3 = + newEvent + (UTCTime (fromGregorian 2026 3 5) (secondsToDiffTime 54321)) + MeetingDelete + ( Qualified + (Id (fromJust (UUID.fromString "00000003-0000-0000-0000-000000000003"))) + (Domain "example.com") + ) + +testObject_Event_meeting_4 :: Event +testObject_Event_meeting_4 = + newEvent + (UTCTime (fromGregorian 2026 4 7) (secondsToDiffTime 1)) + MeetingCreate + ( Qualified + (Id (fromJust (UUID.fromString "00000004-0000-0000-0000-000000000004"))) + (Domain "my-domain.example") + ) + +testObject_Event_meeting_5 :: Event +testObject_Event_meeting_5 = + newEvent + (UTCTime (fromGregorian 2026 5 9) (secondsToDiffTime 86399)) + MeetingUpdate + ( Qualified + (Id (fromJust (UUID.fromString "00000005-0000-0000-0000-000000000005"))) + (Domain "wire.com") + ) + +testObject_Event_meeting_6 :: Event +testObject_Event_meeting_6 = + newEvent + (UTCTime (fromGregorian 2026 6 11) (secondsToDiffTime 3600)) + MeetingDelete + ( Qualified + (Id (fromJust (UUID.fromString "00000006-0000-0000-0000-000000000006"))) + (Domain "wire.com") + ) diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_1.json new file mode 100644 index 00000000000..49558626139 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_1.json @@ -0,0 +1,8 @@ +{ + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "time": "2026-01-01T00:00:00Z", + "type": "meeting.create" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_2.json b/libs/wire-api/test/golden/testObject_Event_meeting_2.json new file mode 100644 index 00000000000..64e0278c71c --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_2.json @@ -0,0 +1,8 @@ +{ + "qualified_id": { + "domain": "example.com", + "id": "00000002-0000-0000-0000-000000000002" + }, + "time": "2026-02-03T10:20:15Z", + "type": "meeting.update" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_3.json b/libs/wire-api/test/golden/testObject_Event_meeting_3.json new file mode 100644 index 00000000000..238480ecf53 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_3.json @@ -0,0 +1,8 @@ +{ + "qualified_id": { + "domain": "example.com", + "id": "00000003-0000-0000-0000-000000000003" + }, + "time": "2026-03-05T15:05:21Z", + "type": "meeting.delete" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_4.json b/libs/wire-api/test/golden/testObject_Event_meeting_4.json new file mode 100644 index 00000000000..48e148ee587 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_4.json @@ -0,0 +1,8 @@ +{ + "qualified_id": { + "domain": "my-domain.example", + "id": "00000004-0000-0000-0000-000000000004" + }, + "time": "2026-04-07T00:00:01Z", + "type": "meeting.create" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_5.json b/libs/wire-api/test/golden/testObject_Event_meeting_5.json new file mode 100644 index 00000000000..816836ef55f --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_5.json @@ -0,0 +1,8 @@ +{ + "qualified_id": { + "domain": "wire.com", + "id": "00000005-0000-0000-0000-000000000005" + }, + "time": "2026-05-09T23:59:59Z", + "type": "meeting.update" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_6.json b/libs/wire-api/test/golden/testObject_Event_meeting_6.json new file mode 100644 index 00000000000..0c772bc71a0 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_6.json @@ -0,0 +1,8 @@ +{ + "qualified_id": { + "domain": "wire.com", + "id": "00000006-0000-0000-0000-000000000006" + }, + "time": "2026-06-11T01:00:00Z", + "type": "meeting.delete" +} diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 1ef9703bef6..a9e7825ce3f 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -105,6 +105,7 @@ library Wire.API.Event.Federation Wire.API.Event.Gundeck Wire.API.Event.LeaveReason + Wire.API.Event.Meeting Wire.API.Event.Team Wire.API.Event.WebSocketProtocol Wire.API.FederationStatus @@ -461,6 +462,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Generated.EmailUpdate_user Test.Wire.API.Golden.Generated.Event_conversation Test.Wire.API.Golden.Generated.Event_featureConfig + Test.Wire.API.Golden.Generated.Event_meeting Test.Wire.API.Golden.Generated.Event_team Test.Wire.API.Golden.Generated.Event_user Test.Wire.API.Golden.Generated.EventType_team diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 77602276cad..42963bd6fc5 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -26,6 +26,7 @@ import Data.ByteString.Conversion (toByteString') import Data.Default (def) import Data.Domain (Domain) import Data.Id +import Data.Json.Util (toJSONObject) import Data.Map qualified as Map import Data.Qualified (Local, Qualified (..), inputQualifyLocal, qualifyAs, tDomain, tUnqualified) import Data.Range (Range, unsafeRange) @@ -40,7 +41,9 @@ import Polysemy.TinyLog qualified as TinyLog import System.Logger qualified as Log import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) +import Wire.API.Event.Meeting qualified as MeetingEvent import Wire.API.Meeting qualified as API +import Wire.API.Push.V2 qualified as PushV2 import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig) import Wire.API.User (BaseProtocolTag (BaseProtocolMLSTag), EmailAddress) @@ -49,6 +52,7 @@ import Wire.ConversationSubsystem qualified as ConversationSubsystem import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getFeatureForTeam) import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem +import Wire.NotificationSubsystem import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredConversation @@ -83,6 +87,7 @@ interpretMeetingsSubsystem :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, Member Now r, Member TinyLog r, Member (Error MeetingError) r, @@ -115,6 +120,8 @@ createMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, + Member Now r, Member (Error MeetingError) r ) => Local UserId -> @@ -171,6 +178,9 @@ createMeetingImpl zUser newMeeting = do newMeeting.invitedEmails trial + let qMeetingId = Qualified storedMeeting.id (tDomain zUser) + pushMeetingEvent zUser Nothing storedConv.localMembers MeetingEvent.MeetingCreate qMeetingId + pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting updateMeetingImpl :: @@ -178,6 +188,7 @@ updateMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, Member TinyLog r, Member (Error MeetingError) r, Member Now r @@ -213,6 +224,7 @@ updateMeetingImpl zUser meetingId update validityPeriod = do update.endTime update.recurrence conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId + lift $ pushMeetingEvent zUser Nothing conv.localMembers MeetingEvent.MeetingUpdate meetingId pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting deleteMeetingImpl :: @@ -220,6 +232,7 @@ deleteMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, Member TinyLog r, Member (Error MeetingError) r, Member Now r @@ -243,6 +256,7 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do let convId = meeting.conversationId lConvId = qualifyAs zUser convId conv <- MaybeT $ getMeetingConversationOrFail meetingId convId + lift $ pushMeetingEvent zUser (Just connId) conv.localMembers MeetingEvent.MeetingDelete meetingId when (conv.metadata.cnvmGroupConvType == Just MeetingConversation) $ lift $ void $ @@ -304,6 +318,32 @@ getMeetingConversationOrFail meetingId convId = do . Log.field "meetingId" (toByteString' (qUnqualified meetingId)) pure Nothing +-- | Push a meeting lifecycle event to all local members of the meeting's +-- conversation via the 'NotificationSubsystem'. Meetings are not federated, so +-- only local members are notified. +pushMeetingEvent :: + ( Member NotificationSubsystem r, + Member Now r + ) => + Local UserId -> + Maybe ConnId -> + [LocalMember] -> + MeetingEvent.EventType -> + Qualified MeetingId -> + Sem r () +pushMeetingEvent lUser conn members ty qMeetingId = do + now <- Now.get + let evt = MeetingEvent.newEvent now ty qMeetingId + pushNotifications + [ def + { origin = Just (tUnqualified lUser), + json = toJSONObject evt, + recipients = map localMemberToRecipient members, + route = PushV2.RouteDirect, + conn + } + ] + -- Helper function to convert StoredMeeting to API.Meeting storedMeetingToMeeting :: Domain -> Store.StoredMeeting -> API.Meeting storedMeetingToMeeting domain sm = diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 03fb7b2288a..c1e497901b9 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -17,6 +17,7 @@ module Wire.MeetingsSubsystem.InterpreterSpec (spec) where +import Data.Aeson (Result (..), Value (Object), fromJSON) import Data.ByteString.Char8 qualified as C import Data.Default (def) import Data.Domain (Domain (..)) @@ -43,6 +44,7 @@ import Text.Email.Parser (unsafeEmailAddress) import Wire.API.Conversation (Access (InviteAccess, PrivateAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess)) import Wire.API.Error (ErrorS) import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) +import Wire.API.Event.Meeting qualified as MeetingEvent import Wire.API.Meeting qualified as API import Wire.API.Team.Feature import Wire.API.Team.Member (TeamMember, mkTeamMember) @@ -54,6 +56,7 @@ import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem import Wire.MeetingsSubsystem.Interpreter import Wire.MockInterpreters +import Wire.NotificationSubsystem (NotificationSubsystem, Push (..)) import Wire.Sem.Logger.TinyLog (discardTinyLogs) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) @@ -67,11 +70,13 @@ type TestStack = ConversationSubsystem, TeamSubsystem, FeaturesConfigSubsystem, + NotificationSubsystem, TinyLog, Error MeetingError, State (Map MeetingId Store.StoredMeeting), State (Map ConvId StoredConversation), State (Map ConvId (Set UserId)), + State [Push], GalleyAPIAccess, Now, State UTCTime, @@ -117,17 +122,25 @@ runTestStack now gen teams configs = . evalState now . interpretNowAsState . miniGalleyAPIAccess teams configs + . evalState ([] :: [Push]) . evalState Map.empty . evalState Map.empty . evalState Map.empty . runError @MeetingError . discardTinyLogs + . inMemoryNotificationSubsystemInterpreter . interpretFeaturesConfigSubsystemPure configs . interpretTeamSubsystemToGalleyAPI . inMemoryConversationSubsystemInterpreter . inMemoryMeetingsStoreInterpreter . interpretMeetingsSubsystem 3600 +-- | Decode all 'Push' payloads that are valid 'MeetingEvent.Event' values. +extractMeetingEvents :: [Push] -> [MeetingEvent.Event] +extractMeetingEvents pushes = + [ e | push <- pushes, Success e <- [fromJSON (Object push.json)] + ] + spec :: Spec spec = describe "MeetingsSubsystem.Interpreter" $ do it "creates a meeting and can retrieve it" $ do @@ -1191,3 +1204,98 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do replaceInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled + + describe "meeting events" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid1 = Id $ read "00000000-0000-0000-0000-000000000001" + uid2 = Id $ read "00000000-0000-0000-0000-000000000002" + zUser1 = toLocalUnsafe (Domain "wire.com") uid1 + zUser2 = toLocalUnsafe (Domain "wire.com") uid2 + teamId = Id $ read "00000000-0000-0000-0000-000000000100" + teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled + teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled + teamConfig = + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) def + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Event Test Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + recurrence = Nothing, + invitedEmails = [] + } + + it "emits a meeting.create event on successful create" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + pushes <- get @[Push] + pure (meeting, pushes) + + case result of + Left err -> fail $ "Error: " <> show err + Right (meeting, pushes) -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + let evt = head events + evt.evtType `shouldBe` MeetingEvent.MeetingCreate + evt.evtQualifiedId `shouldBe` meeting.meeting.id + + it "emits a meeting.update event on successful update" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + pushes <- get @[Push] + pure pushes + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + (head events).evtType `shouldBe` MeetingEvent.MeetingUpdate + + it "emits a meeting.delete event on successful delete" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- deleteMeeting zUser1 (ConnId "test-conn") meeting.meeting.id + pushes <- get @[Push] + pure pushes + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + (head events).evtType `shouldBe` MeetingEvent.MeetingDelete + + it "does not emit an event when updateMeeting fails (non-creator)" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- updateMeeting zUser2 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing) + pushes <- get @[Push] + pure pushes + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> extractMeetingEvents pushes `shouldBe` [] + + it "does not emit an event when deleteMeeting fails (non-creator)" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- deleteMeeting zUser2 (ConnId "test-conn") meeting.meeting.id + pushes <- get @[Push] + pure pushes + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> extractMeetingEvents pushes `shouldBe` [] From 8728160a708050d5eba60c47d65f83790aaff28b Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 9 Jul 2026 23:45:35 +0200 Subject: [PATCH 02/10] Hello CI From 76aca899f30a44bfe8a7966a1ccbece026feb056 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 11:37:04 +0200 Subject: [PATCH 03/10] Update libs/wire-api/src/Wire/API/Event/Meeting.hs Co-authored-by: Leif Battermann --- libs/wire-api/src/Wire/API/Event/Meeting.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs index 9be05e2a248..a4ce02c1c54 100644 --- a/libs/wire-api/src/Wire/API/Event/Meeting.hs +++ b/libs/wire-api/src/Wire/API/Event/Meeting.hs @@ -78,7 +78,7 @@ instance ToSchema Event where instance ToJSONObject Event where toJSONObject e = case toJSON e of Object o -> o - _ -> error "Wire.API.Event.Meeting.Event.toJSON did not produce an Object" + _ -> KeyMap.fromList [] newEvent :: UTCTime -> EventType -> Qualified MeetingId -> Event newEvent time ty qid = Event ty time qid From ee701dea79a6bb8bd9d08b5750862115bc20f314 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 11:50:26 +0200 Subject: [PATCH 04/10] Address PR review: use schemaOut for ToJSONObject, send delete event after successful deletion --- libs/wire-api/src/Wire/API/Event/Meeting.hs | 22 ++++++++++--------- .../src/Wire/MeetingsSubsystem/Interpreter.hs | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs index a4ce02c1c54..6eb5c52afac 100644 --- a/libs/wire-api/src/Wire/API/Event/Meeting.hs +++ b/libs/wire-api/src/Wire/API/Event/Meeting.hs @@ -28,7 +28,8 @@ module Wire.API.Event.Meeting ) where -import Data.Aeson (FromJSON, ToJSON (toJSON), Value (Object)) +import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson.KeyMap qualified as KeyMap import Data.Id (MeetingId) import Data.Json.Util (ToJSONObject (toJSONObject), utcTimeSchema) import Data.OpenApi qualified as S @@ -68,17 +69,18 @@ data Event = Event deriving (Arbitrary) via (GenericUniform Event) instance ToSchema Event where - schema = - object $ - Event - <$> (.evtType) .= field "type" schema - <*> (.evtTime) .= field "time" utcTimeSchema - <*> (.evtQualifiedId) .= field "qualified_id" schema + schema = object eventObjectSchema + +eventObjectSchema :: ObjectSchema SwaggerDoc Event +eventObjectSchema = + Event + <$> (.evtType) .= field "type" schema + <*> (.evtTime) .= field "time" utcTimeSchema + <*> (.evtQualifiedId) .= field "qualified_id" schema instance ToJSONObject Event where - toJSONObject e = case toJSON e of - Object o -> o - _ -> KeyMap.fromList [] + toJSONObject = + KeyMap.fromList . fromMaybe [] . schemaOut eventObjectSchema newEvent :: UTCTime -> EventType -> Qualified MeetingId -> Event newEvent time ty qid = Event ty time qid diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 42963bd6fc5..6608b0ec859 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -256,12 +256,12 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do let convId = meeting.conversationId lConvId = qualifyAs zUser convId conv <- MaybeT $ getMeetingConversationOrFail meetingId convId - lift $ pushMeetingEvent zUser (Just connId) conv.localMembers MeetingEvent.MeetingDelete meetingId when (conv.metadata.cnvmGroupConvType == Just MeetingConversation) $ lift $ void $ ConversationSubsystem.deleteLocalConversation zUser connId lConvId lift $ Store.deleteMeeting (qUnqualified meetingId) + lift $ pushMeetingEvent zUser (Just connId) conv.localMembers MeetingEvent.MeetingDelete meetingId pure $ isJust result getMeetingImpl :: From 5a34e1727a590d01d26cc811c848e6d692544c87 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 12:13:39 +0200 Subject: [PATCH 05/10] fixup! WPB-26705: add meeting.create/update/delete lifecycle events --- .../Test/Wire/API/Golden/Generated/Event_meeting.hs | 2 -- .../unit/Wire/MeetingsSubsystem/InterpreterSpec.hs | 12 ++++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs index c207a6bcf44..f795b3751ff 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs @@ -1,5 +1,3 @@ -{-# LANGUAGE OverloadedLists #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2026 Wire Swiss GmbH diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index c1e497901b9..200d4414850 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -1248,8 +1248,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do meeting <- createMeeting zUser1 newMeeting put @[Push] [] _ <- updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) - pushes <- get @[Push] - pure pushes + get @[Push] case result of Left err -> fail $ "Error: " <> show err @@ -1264,8 +1263,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do meeting <- createMeeting zUser1 newMeeting put @[Push] [] _ <- deleteMeeting zUser1 (ConnId "test-conn") meeting.meeting.id - pushes <- get @[Push] - pure pushes + get @[Push] case result of Left err -> fail $ "Error: " <> show err @@ -1280,8 +1278,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do meeting <- createMeeting zUser1 newMeeting put @[Push] [] _ <- updateMeeting zUser2 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing) - pushes <- get @[Push] - pure pushes + get @[Push] case result of Left err -> fail $ "Error: " <> show err @@ -1293,8 +1290,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do meeting <- createMeeting zUser1 newMeeting put @[Push] [] _ <- deleteMeeting zUser2 (ConnId "test-conn") meeting.meeting.id - pushes <- get @[Push] - pure pushes + get @[Push] case result of Left err -> fail $ "Error: " <> show err From 26751bd157a85307af1405657746678e8ece92d4 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 13:03:19 +0200 Subject: [PATCH 06/10] Fold meeting lifecycle events into Wire.API.Event.Conversation --- .../2-features/wpb-26705-meeting-events.md | 6 +- .../src/Wire/API/Event/Conversation.hs | 26 +++++- libs/wire-api/src/Wire/API/Event/Meeting.hs | 89 ------------------- .../golden/Test/Wire/API/Golden/Generated.hs | 10 --- .../API/Golden/Generated/Event_meeting.hs | 86 ------------------ .../golden/testObject_Event_meeting_1.json | 8 -- .../golden/testObject_Event_meeting_2.json | 8 -- .../golden/testObject_Event_meeting_3.json | 8 -- .../golden/testObject_Event_meeting_4.json | 8 -- .../golden/testObject_Event_meeting_5.json | 8 -- .../golden/testObject_Event_meeting_6.json | 8 -- .../test/unit/Test/Wire/API/Conversation.hs | 3 + libs/wire-api/wire-api.cabal | 2 - .../src/Wire/MeetingsSubsystem/Interpreter.hs | 27 ++++-- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 32 +++++-- 15 files changed, 74 insertions(+), 255 deletions(-) delete mode 100644 libs/wire-api/src/Wire/API/Event/Meeting.hs delete mode 100644 libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs delete mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_1.json delete mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_2.json delete mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_3.json delete mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_4.json delete mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_5.json delete mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_6.json diff --git a/changelog.d/2-features/wpb-26705-meeting-events.md b/changelog.d/2-features/wpb-26705-meeting-events.md index 782f2cd7e38..6eff9dce988 100644 --- a/changelog.d/2-features/wpb-26705-meeting-events.md +++ b/changelog.d/2-features/wpb-26705-meeting-events.md @@ -1,5 +1,7 @@ * Added meeting lifecycle events: `meeting.create`, `meeting.update`, and `meeting.delete` (WPB-26705). These websocket notifications are pushed to all local members of the meeting's conversation on every successful create, update, - and delete operation. Each event payload contains the event `type`, `time`, and - the `qualified_id` of the affected meeting. + and delete operation. The events use the standard conversation event envelope: + each payload contains the event `type`, the meeting's qualified ID in the `data` + field, the `qualified_conversation`, `qualified_from`, `via`, `time`, and + optional `team`. diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index a75477d2ade..5ae6a3a5b89 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -58,6 +58,9 @@ module Wire.API.Event.Conversation _EdMLSMessage, _EdMLSWelcome, _EdAddPermissionUpdate, + _EdMeetingCreate, + _EdMeetingUpdate, + _EdMeetingDelete, -- * Event data helpers SimpleMember (..), @@ -194,6 +197,9 @@ data EventType | ProtocolUpdate | AddPermissionUpdate | ConvHistoryUpdate + | MeetingCreate + | MeetingUpdate + | MeetingDelete deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) deriving (Arbitrary) via (GenericUniform EventType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType @@ -222,7 +228,10 @@ instance ToSchema EventType where element "conversation.mls-welcome" MLSWelcome, element "conversation.protocol-update" ProtocolUpdate, element "conversation.add-permission-update" AddPermissionUpdate, - element "conversation.history-update" ConvHistoryUpdate + element "conversation.history-update" ConvHistoryUpdate, + element "meeting.create" MeetingCreate, + element "meeting.update" MeetingUpdate, + element "meeting.delete" MeetingDelete ] data EventData @@ -247,6 +256,9 @@ data EventData | EdProtocolUpdate P.ProtocolTag | EdAddPermissionUpdate Conv.AddPermissionUpdate | EdConvHistoryUpdate History + | EdMeetingCreate (Qualified MeetingId) + | EdMeetingUpdate (Qualified MeetingId) + | EdMeetingDelete (Qualified MeetingId) deriving stock (Eq, Show, Generic) genEventData :: EventType -> QC.Gen EventData @@ -272,6 +284,9 @@ genEventData = \case ProtocolUpdate -> EdProtocolUpdate <$> arbitrary AddPermissionUpdate -> EdAddPermissionUpdate <$> arbitrary ConvHistoryUpdate -> EdConvHistoryUpdate <$> arbitrary + MeetingCreate -> EdMeetingCreate <$> arbitrary + MeetingUpdate -> EdMeetingUpdate <$> arbitrary + MeetingDelete -> EdMeetingDelete <$> arbitrary eventDataType :: EventData -> EventType eventDataType (EdMembersJoin _) = MemberJoin @@ -295,6 +310,9 @@ eventDataType (EdConvReset _) = ConvReset eventDataType (EdProtocolUpdate _) = ProtocolUpdate eventDataType (EdAddPermissionUpdate _) = AddPermissionUpdate eventDataType (EdConvHistoryUpdate _) = ConvHistoryUpdate +eventDataType (EdMeetingCreate _) = MeetingCreate +eventDataType (EdMeetingUpdate _) = MeetingUpdate +eventDataType (EdMeetingDelete _) = MeetingDelete createConversationEventData :: OwnConversation GroupConvType -> EventData @@ -326,6 +344,9 @@ isCellsConversationEvent eventType = ProtocolUpdate -> False AddPermissionUpdate -> False ConvHistoryUpdate -> False + MeetingCreate -> False + MeetingUpdate -> False + MeetingDelete -> False -------------------------------------------------------------------------------- -- Event data helpers @@ -529,6 +550,9 @@ taggedEventDataSchema = ProtocolUpdate -> tag _EdProtocolUpdate (unnamed (unProtocolUpdate <$> P.ProtocolUpdate .= schema)) AddPermissionUpdate -> tag _EdAddPermissionUpdate (unnamed schema) ConvHistoryUpdate -> tag _EdConvHistoryUpdate (unnamed schema) + MeetingCreate -> tag _EdMeetingCreate (unnamed schema) + MeetingUpdate -> tag _EdMeetingUpdate (unnamed schema) + MeetingDelete -> tag _EdMeetingDelete (unnamed schema) memberLeaveSchema :: ValueSchema NamedSwaggerDoc (EdMemberLeftReason, QualifiedUserIdList) memberLeaveSchema = diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs deleted file mode 100644 index 6eb5c52afac..00000000000 --- a/libs/wire-api/src/Wire/API/Event/Meeting.hs +++ /dev/null @@ -1,89 +0,0 @@ -{-# LANGUAGE StrictData #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2026 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Wire.API.Event.Meeting - ( -- * Event - Event (..), - newEvent, - eventType, - - -- * EventType - EventType (..), - ) -where - -import Data.Aeson (FromJSON, ToJSON) -import Data.Aeson.KeyMap qualified as KeyMap -import Data.Id (MeetingId) -import Data.Json.Util (ToJSONObject (toJSONObject), utcTimeSchema) -import Data.OpenApi qualified as S -import Data.Qualified (Qualified) -import Data.Schema -import Data.Time.Clock (UTCTime) -import Imports -import Wire.Arbitrary (Arbitrary, GenericUniform (..)) - --------------------------------------------------------------------------------- --- EventType - -data EventType = MeetingCreate | MeetingUpdate | MeetingDelete - deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) - deriving (Arbitrary) via (GenericUniform EventType) - deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType - -instance ToSchema EventType where - schema = - enum @Text $ - mconcat - [ element "meeting.create" MeetingCreate, - element "meeting.update" MeetingUpdate, - element "meeting.delete" MeetingDelete - ] - --------------------------------------------------------------------------------- --- Event - -data Event = Event - { evtType :: EventType, - evtTime :: UTCTime, - evtQualifiedId :: Qualified MeetingId - } - deriving stock (Eq, Show, Generic) - deriving (ToJSON, FromJSON, S.ToSchema) via Schema Event - deriving (Arbitrary) via (GenericUniform Event) - -instance ToSchema Event where - schema = object eventObjectSchema - -eventObjectSchema :: ObjectSchema SwaggerDoc Event -eventObjectSchema = - Event - <$> (.evtType) .= field "type" schema - <*> (.evtTime) .= field "time" utcTimeSchema - <*> (.evtQualifiedId) .= field "qualified_id" schema - -instance ToJSONObject Event where - toJSONObject = - KeyMap.fromList . fromMaybe [] . schemaOut eventObjectSchema - -newEvent :: UTCTime -> EventType -> Qualified MeetingId -> Event -newEvent time ty qid = Event ty time qid - -eventType :: Event -> EventType -eventType = (.evtType) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs index 916894af355..02dc1d21e43 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs @@ -83,7 +83,6 @@ import Test.Wire.API.Golden.Generated.EventType_team qualified import Test.Wire.API.Golden.Generated.EventType_user qualified import Test.Wire.API.Golden.Generated.Event_conversation qualified import Test.Wire.API.Golden.Generated.Event_featureConfig qualified -import Test.Wire.API.Golden.Generated.Event_meeting qualified import Test.Wire.API.Golden.Generated.Event_team qualified import Test.Wire.API.Golden.Generated.Event_user qualified import Test.Wire.API.Golden.Generated.Feature_team qualified @@ -1499,15 +1498,6 @@ tests = (Test.Wire.API.Golden.Generated.Event_conversation.testObject_Event_conversation_11, "testObject_Event_conversation_11.json"), (Test.Wire.API.Golden.Generated.Event_conversation.testObject_Event_conversation_10, "testObject_Event_conversation_10.json") ], - testGroup "Golden: Event_meeting" $ - testObjects - [ (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_1, "testObject_Event_meeting_1.json"), - (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_2, "testObject_Event_meeting_2.json"), - (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_3, "testObject_Event_meeting_3.json"), - (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_4, "testObject_Event_meeting_4.json"), - (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_5, "testObject_Event_meeting_5.json"), - (Test.Wire.API.Golden.Generated.Event_meeting.testObject_Event_meeting_6, "testObject_Event_meeting_6.json") - ], testGroup "Golden: QualifiedUserClientPrekeyMapV4" $ testObjects [ (Test.Wire.API.Golden.Generated.QualifiedUserClientPrekeyMapV4_user.testObject_QualifiedUserClientPrekeyMapV4_user_1, "testObject_QualifiedUserClientPrekeyMapV4_1.json"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs deleted file mode 100644 index f795b3751ff..00000000000 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_meeting.hs +++ /dev/null @@ -1,86 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2026 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Test.Wire.API.Golden.Generated.Event_meeting where - -import Data.Domain (Domain (..)) -import Data.Id (Id (Id)) -import Data.Qualified (Qualified (..)) -import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime) -import Data.UUID qualified as UUID -import Imports (fromJust) -import Wire.API.Event.Meeting - -testObject_Event_meeting_1 :: Event -testObject_Event_meeting_1 = - newEvent - (UTCTime (fromGregorian 2026 1 1) (secondsToDiffTime 0)) - MeetingCreate - ( Qualified - (Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001"))) - (Domain "example.com") - ) - -testObject_Event_meeting_2 :: Event -testObject_Event_meeting_2 = - newEvent - (UTCTime (fromGregorian 2026 2 3) (secondsToDiffTime 37215)) - MeetingUpdate - ( Qualified - (Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002"))) - (Domain "example.com") - ) - -testObject_Event_meeting_3 :: Event -testObject_Event_meeting_3 = - newEvent - (UTCTime (fromGregorian 2026 3 5) (secondsToDiffTime 54321)) - MeetingDelete - ( Qualified - (Id (fromJust (UUID.fromString "00000003-0000-0000-0000-000000000003"))) - (Domain "example.com") - ) - -testObject_Event_meeting_4 :: Event -testObject_Event_meeting_4 = - newEvent - (UTCTime (fromGregorian 2026 4 7) (secondsToDiffTime 1)) - MeetingCreate - ( Qualified - (Id (fromJust (UUID.fromString "00000004-0000-0000-0000-000000000004"))) - (Domain "my-domain.example") - ) - -testObject_Event_meeting_5 :: Event -testObject_Event_meeting_5 = - newEvent - (UTCTime (fromGregorian 2026 5 9) (secondsToDiffTime 86399)) - MeetingUpdate - ( Qualified - (Id (fromJust (UUID.fromString "00000005-0000-0000-0000-000000000005"))) - (Domain "wire.com") - ) - -testObject_Event_meeting_6 :: Event -testObject_Event_meeting_6 = - newEvent - (UTCTime (fromGregorian 2026 6 11) (secondsToDiffTime 3600)) - MeetingDelete - ( Qualified - (Id (fromJust (UUID.fromString "00000006-0000-0000-0000-000000000006"))) - (Domain "wire.com") - ) diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_1.json deleted file mode 100644 index 49558626139..00000000000 --- a/libs/wire-api/test/golden/testObject_Event_meeting_1.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, - "time": "2026-01-01T00:00:00Z", - "type": "meeting.create" -} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_2.json b/libs/wire-api/test/golden/testObject_Event_meeting_2.json deleted file mode 100644 index 64e0278c71c..00000000000 --- a/libs/wire-api/test/golden/testObject_Event_meeting_2.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "qualified_id": { - "domain": "example.com", - "id": "00000002-0000-0000-0000-000000000002" - }, - "time": "2026-02-03T10:20:15Z", - "type": "meeting.update" -} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_3.json b/libs/wire-api/test/golden/testObject_Event_meeting_3.json deleted file mode 100644 index 238480ecf53..00000000000 --- a/libs/wire-api/test/golden/testObject_Event_meeting_3.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "qualified_id": { - "domain": "example.com", - "id": "00000003-0000-0000-0000-000000000003" - }, - "time": "2026-03-05T15:05:21Z", - "type": "meeting.delete" -} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_4.json b/libs/wire-api/test/golden/testObject_Event_meeting_4.json deleted file mode 100644 index 48e148ee587..00000000000 --- a/libs/wire-api/test/golden/testObject_Event_meeting_4.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "qualified_id": { - "domain": "my-domain.example", - "id": "00000004-0000-0000-0000-000000000004" - }, - "time": "2026-04-07T00:00:01Z", - "type": "meeting.create" -} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_5.json b/libs/wire-api/test/golden/testObject_Event_meeting_5.json deleted file mode 100644 index 816836ef55f..00000000000 --- a/libs/wire-api/test/golden/testObject_Event_meeting_5.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "qualified_id": { - "domain": "wire.com", - "id": "00000005-0000-0000-0000-000000000005" - }, - "time": "2026-05-09T23:59:59Z", - "type": "meeting.update" -} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_6.json b/libs/wire-api/test/golden/testObject_Event_meeting_6.json deleted file mode 100644 index 0c772bc71a0..00000000000 --- a/libs/wire-api/test/golden/testObject_Event_meeting_6.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "qualified_id": { - "domain": "wire.com", - "id": "00000006-0000-0000-0000-000000000006" - }, - "time": "2026-06-11T01:00:00Z", - "type": "meeting.delete" -} diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs index aa9527a3e69..4c9f6fbe679 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs @@ -85,6 +85,9 @@ testIsCellsConversationEvent = OtrMessageAdd -> isCellsConversationEvent e === False ProtocolUpdate -> isCellsConversationEvent e === False Typing -> isCellsConversationEvent e === False + MeetingCreate -> isCellsConversationEvent e === False + MeetingUpdate -> isCellsConversationEvent e === False + MeetingDelete -> isCellsConversationEvent e === False -------------------------------------------------------------------------------- -- Legacy conversion tests diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index a9e7825ce3f..1ef9703bef6 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -105,7 +105,6 @@ library Wire.API.Event.Federation Wire.API.Event.Gundeck Wire.API.Event.LeaveReason - Wire.API.Event.Meeting Wire.API.Event.Team Wire.API.Event.WebSocketProtocol Wire.API.FederationStatus @@ -462,7 +461,6 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Generated.EmailUpdate_user Test.Wire.API.Golden.Generated.Event_conversation Test.Wire.API.Golden.Generated.Event_featureConfig - Test.Wire.API.Golden.Generated.Event_meeting Test.Wire.API.Golden.Generated.Event_team Test.Wire.API.Golden.Generated.Event_user Test.Wire.API.Golden.Generated.EventType_team diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 6608b0ec859..38ccb3f569f 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -41,7 +41,7 @@ import Polysemy.TinyLog qualified as TinyLog import System.Logger qualified as Log import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) -import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.API.Event.Conversation qualified as ConvEvent import Wire.API.Meeting qualified as API import Wire.API.Push.V2 qualified as PushV2 import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging @@ -179,7 +179,7 @@ createMeetingImpl zUser newMeeting = do trial let qMeetingId = Qualified storedMeeting.id (tDomain zUser) - pushMeetingEvent zUser Nothing storedConv.localMembers MeetingEvent.MeetingCreate qMeetingId + pushMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId (ConvEvent.EdMeetingCreate qMeetingId) pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting @@ -224,7 +224,7 @@ updateMeetingImpl zUser meetingId update validityPeriod = do update.endTime update.recurrence conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId - lift $ pushMeetingEvent zUser Nothing conv.localMembers MeetingEvent.MeetingUpdate meetingId + lift $ pushMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingUpdate meetingId) pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting deleteMeetingImpl :: @@ -261,7 +261,7 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do void $ ConversationSubsystem.deleteLocalConversation zUser connId lConvId lift $ Store.deleteMeeting (qUnqualified meetingId) - lift $ pushMeetingEvent zUser (Just connId) conv.localMembers MeetingEvent.MeetingDelete meetingId + lift $ pushMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingDelete meetingId) pure $ isJust result getMeetingImpl :: @@ -328,12 +328,23 @@ pushMeetingEvent :: Local UserId -> Maybe ConnId -> [LocalMember] -> - MeetingEvent.EventType -> - Qualified MeetingId -> + Qualified ConvId -> + Maybe TeamId -> + ConvEvent.EventData -> Sem r () -pushMeetingEvent lUser conn members ty qMeetingId = do +pushMeetingEvent lUser conn members qConvId mTeamId edata = do now <- Now.get - let evt = MeetingEvent.newEvent now ty qMeetingId + let evt = + ConvEvent.Event + { evtConv = qConvId, + evtSubConv = Nothing, + evtFrom = + ConvEvent.EventFromUser + (Qualified (tUnqualified lUser) (tDomain lUser)), + evtTime = now, + evtTeam = mTeamId, + evtData = edata + } pushNotifications [ def { origin = Just (tUnqualified lUser), diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 200d4414850..fd63a40687a 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -44,7 +44,7 @@ import Text.Email.Parser (unsafeEmailAddress) import Wire.API.Conversation (Access (InviteAccess, PrivateAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess)) import Wire.API.Error (ErrorS) import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) -import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.API.Event.Conversation qualified as ConvEvent import Wire.API.Meeting qualified as API import Wire.API.Team.Feature import Wire.API.Team.Member (TeamMember, mkTeamMember) @@ -135,11 +135,21 @@ runTestStack now gen teams configs = . inMemoryMeetingsStoreInterpreter . interpretMeetingsSubsystem 3600 --- | Decode all 'Push' payloads that are valid 'MeetingEvent.Event' values. -extractMeetingEvents :: [Push] -> [MeetingEvent.Event] +-- | Decode all 'Push' payloads that are conversation events carrying meeting +-- lifecycle data. +extractMeetingEvents :: [Push] -> [ConvEvent.Event] extractMeetingEvents pushes = - [ e | push <- pushes, Success e <- [fromJSON (Object push.json)] + [ e + | push <- pushes, + Success e <- [fromJSON (Object push.json)], + isMeetingEvent e ] + where + isMeetingEvent e = case e.evtData of + ConvEvent.EdMeetingCreate _ -> True + ConvEvent.EdMeetingUpdate _ -> True + ConvEvent.EdMeetingDelete _ -> True + _ -> False spec :: Spec spec = describe "MeetingsSubsystem.Interpreter" $ do @@ -1238,9 +1248,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do Right (meeting, pushes) -> do let events = extractMeetingEvents pushes length events `shouldBe` 1 - let evt = head events - evt.evtType `shouldBe` MeetingEvent.MeetingCreate - evt.evtQualifiedId `shouldBe` meeting.meeting.id + case (head events).evtData of + ConvEvent.EdMeetingCreate mid -> mid `shouldBe` meeting.meeting.id + other -> fail $ "expected EdMeetingCreate, got " <> show other it "emits a meeting.update event on successful update" $ do result <- @@ -1255,7 +1265,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do Right pushes -> do let events = extractMeetingEvents pushes length events `shouldBe` 1 - (head events).evtType `shouldBe` MeetingEvent.MeetingUpdate + case (head events).evtData of + ConvEvent.EdMeetingUpdate _ -> pure () + other -> fail $ "expected EdMeetingUpdate, got " <> show other it "emits a meeting.delete event on successful delete" $ do result <- @@ -1270,7 +1282,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do Right pushes -> do let events = extractMeetingEvents pushes length events `shouldBe` 1 - (head events).evtType `shouldBe` MeetingEvent.MeetingDelete + case (head events).evtData of + ConvEvent.EdMeetingDelete _ -> pure () + other -> fail $ "expected EdMeetingDelete, got " <> show other it "does not emit an event when updateMeeting fails (non-creator)" $ do result <- From a038e25f8d85c9eca9885f736a61f59124e39cf8 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 14:50:35 +0200 Subject: [PATCH 07/10] Hello CI From e9003b168a347585afebc60646af1a2c7eaf7bf1 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 16:12:13 +0200 Subject: [PATCH 08/10] Hello CI From ab84f3a1e43abf90bcfe37bd775f4c643713c27b Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 19:08:48 +0200 Subject: [PATCH 09/10] Hello CI From d4c7483681989a0ed8cf1114cfbf8446977b8382 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 19:57:26 +0200 Subject: [PATCH 10/10] Hello CI