Skip to content
7 changes: 7 additions & 0 deletions changelog.d/2-features/wpb-26705-meeting-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +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. 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`.
12 changes: 12 additions & 0 deletions integration/test/Notifications.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
14 changes: 10 additions & 4 deletions integration/test/Test/Meetings.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ()
Expand Down
26 changes: 25 additions & 1 deletion libs/wire-api/src/Wire/API/Event/Conversation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ module Wire.API.Event.Conversation
_EdMLSMessage,
_EdMLSWelcome,
_EdAddPermissionUpdate,
_EdMeetingCreate,
_EdMeetingUpdate,
_EdMeetingDelete,

-- * Event data helpers
SimpleMember (..),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -326,6 +344,9 @@ isCellsConversationEvent eventType =
ProtocolUpdate -> False
AddPermissionUpdate -> False
ConvHistoryUpdate -> False
MeetingCreate -> False
MeetingUpdate -> False
MeetingDelete -> False

--------------------------------------------------------------------------------
-- Event data helpers
Expand Down Expand Up @@ -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 =
Expand Down
3 changes: 3 additions & 0 deletions libs/wire-api/test/unit/Test/Wire/API/Conversation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.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
import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig)
import Wire.API.User (BaseProtocolTag (BaseProtocolMLSTag), EmailAddress)
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -171,13 +178,17 @@ createMeetingImpl zUser newMeeting = do
newMeeting.invitedEmails
trial

let qMeetingId = Qualified storedMeeting.id (tDomain zUser)
pushMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId (ConvEvent.EdMeetingCreate qMeetingId)

pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting

updateMeetingImpl ::
( Member Store.MeetingsStore r,
Member ConversationSubsystem r,
Member TeamSubsystem r,
Member FeaturesConfigSubsystem r,
Member NotificationSubsystem r,
Member TinyLog r,
Member (Error MeetingError) r,
Member Now r
Expand Down Expand Up @@ -213,13 +224,15 @@ updateMeetingImpl zUser meetingId update validityPeriod = do
update.endTime
update.recurrence
conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId
lift $ pushMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingUpdate meetingId)
pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting

deleteMeetingImpl ::
( Member Store.MeetingsStore r,
Member ConversationSubsystem r,
Member TeamSubsystem r,
Member FeaturesConfigSubsystem r,
Member NotificationSubsystem r,
Member TinyLog r,
Member (Error MeetingError) r,
Member Now r
Expand Down Expand Up @@ -248,6 +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 (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingDelete meetingId)
pure $ isJust result

getMeetingImpl ::
Expand Down Expand Up @@ -304,6 +318,43 @@ 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] ->
Qualified ConvId ->
Maybe TeamId ->
ConvEvent.EventData ->
Sem r ()
pushMeetingEvent lUser conn members qConvId mTeamId edata = do
now <- Now.get
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),
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 =
Expand Down
Loading