11import type { ICalendarService } from '@rocket.chat/core-services' ;
2- import { ServiceClassInternal , api } from '@rocket.chat/core-services' ;
2+ import { Presence , ServiceClassInternal , api } from '@rocket.chat/core-services' ;
33import type { IUser , ICalendarEvent } from '@rocket.chat/core-typings' ;
44import { UserStatus } from '@rocket.chat/core-typings' ;
55import { cronJobs } from '@rocket.chat/cron' ;
@@ -8,12 +8,10 @@ import type { InsertionModel } from '@rocket.chat/model-typings';
88import { CalendarEvent , Users } from '@rocket.chat/models' ;
99import type { UpdateResult , DeleteResult } from 'mongodb' ;
1010
11- import { applyStatusChange } from './statusEvents/applyStatusChange' ;
12- import { cancelUpcomingStatusChanges } from './statusEvents/cancelUpcomingStatusChanges' ;
13- import { removeCronJobs } from './statusEvents/removeCronJobs' ;
1411import { getShiftedTime } from './utils/getShiftedTime' ;
1512import { settings } from '../../../app/settings/server' ;
1613import { getUserPreference } from '../../../app/utils/server/lib/getUserPreference' ;
14+ import { i18n } from '../../lib/i18n' ;
1715
1816const logger = new Logger ( 'Calendar' ) ;
1917
@@ -44,6 +42,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
4442 await this . setupNextNotification ( ) ;
4543 if ( busy !== false ) {
4644 await this . setupNextStatusChange ( ) ;
45+ await this . reconcileInProgressEvent ( insertResult . insertedId ) ;
4746 }
4847
4948 return insertResult . insertedId ;
@@ -83,6 +82,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
8382 await this . setupNextNotification ( ) ;
8483 if ( busy !== false ) {
8584 await this . setupNextStatusChange ( ) ;
85+ await this . reconcileInProgressEvent ( insertResult . insertedId ) ;
8686 }
8787
8888 return insertResult . insertedId ;
@@ -93,6 +93,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
9393 await this . setupNextNotification ( ) ;
9494 if ( busy !== false ) {
9595 await this . setupNextStatusChange ( ) ;
96+ await this . reconcileInProgressEvent ( event . _id ) ;
9697 }
9798 }
9899
@@ -135,10 +136,10 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
135136 await this . setupNextNotification ( ) ;
136137
137138 if ( startTime || endTime ) {
138- await removeCronJobs ( eventId , event . uid ) ;
139139 const isBusy = busy !== undefined ? busy : event . busy !== false ;
140140 if ( isBusy ) {
141141 await this . setupNextStatusChange ( ) ;
142+ await this . reconcileInProgressEvent ( eventId ) ;
142143 }
143144 }
144145 }
@@ -148,16 +149,21 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
148149
149150 public async delete ( eventId : ICalendarEvent [ '_id' ] ) : Promise < DeleteResult > {
150151 const event = await this . get ( eventId ) ;
151- if ( event ) {
152- await removeCronJobs ( eventId , event . uid ) ;
153- }
152+ const now = new Date ( ) ;
153+ const wasInProgress = Boolean ( event && event . busy !== false && event . startTime <= now && ( ! event . endTime || event . endTime > now ) ) ;
154154
155155 const result = await CalendarEvent . deleteOne ( {
156156 _id : eventId ,
157157 } ) ;
158158
159159 if ( result . deletedCount > 0 ) {
160160 await this . setupNextStatusChange ( ) ;
161+
162+ // Deleting a busy event that was in progress changes "until when" the user is busy: recompute
163+ // from the events still active (lowering the expiry, or clearing if none remain).
164+ if ( event && wasInProgress ) {
165+ await this . syncBusyPresence ( event . uid , { excludeEventId : eventId , now } ) ;
166+ }
161167 }
162168
163169 return result ;
@@ -171,10 +177,6 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
171177 return this . doSetupNextStatusChange ( ) ;
172178 }
173179
174- public async cancelUpcomingStatusChanges ( uid : IUser [ '_id' ] , endTime = new Date ( ) ) : Promise < void > {
175- return cancelUpcomingStatusChanges ( uid , endTime ) ;
176- }
177-
178180 private async getMeetingUrl ( eventData : Partial < ICalendarEvent > ) : Promise < string | undefined > {
179181 if ( eventData . meetingUrl !== undefined ) {
180182 return eventData . meetingUrl || undefined ;
@@ -210,52 +212,29 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
210212 // 2. When a busy event is updated (time/busy status changes)
211213 // 3. When a busy event is deleted
212214 // 4. When a status change job executes and completes
213- // 5. When an event ends and the status is restored
214- // 6. From Outlook Calendar integration (ee/server/configuration/outlookCalendar.ts)
215+ // 5. From Outlook Calendar integration (ee/server/configuration/outlookCalendar.ts)
216+ // Note: event end is handled by the presence engine's expiration cron via statusExpiresAt
217+
218+ const schedulerJobId = 'calendar-status-scheduler' ;
215219
216220 const busyStatusEnabled = settings . get < boolean > ( 'Calendar_BusyStatus_Enabled' ) ;
217221 if ( ! busyStatusEnabled ) {
218- const schedulerJobId = 'calendar-status-scheduler' ;
219222 if ( await cronJobs . has ( schedulerJobId ) ) {
220223 await cronJobs . remove ( schedulerJobId ) ;
221224 }
222225 return ;
223226 }
224227
225- const schedulerJobId = 'calendar-status-scheduler' ;
226228 if ( await cronJobs . has ( schedulerJobId ) ) {
227229 await cronJobs . remove ( schedulerJobId ) ;
228230 }
229231
230- const now = new Date ( ) ;
231- const nextStartEvent = await CalendarEvent . findNextFutureEvent ( now ) ;
232- const inProgressEvents = await CalendarEvent . findInProgressEvents ( now ) . toArray ( ) ;
233- const eventsWithEndTime = inProgressEvents . filter ( ( event ) => event . endTime && event . busy !== false ) ;
234- if ( eventsWithEndTime . length === 0 && ! nextStartEvent ) {
235- return ;
236- }
237-
238- let nextEndTime : Date | null = null ;
239- if ( eventsWithEndTime . length > 0 && eventsWithEndTime [ 0 ] . endTime ) {
240- nextEndTime = eventsWithEndTime . reduce ( ( earliest , event ) => {
241- if ( ! event . endTime ) return earliest ;
242- return event . endTime . getTime ( ) < earliest . getTime ( ) ? event . endTime : earliest ;
243- } , eventsWithEndTime [ 0 ] . endTime ) ;
244- }
245-
246- let nextProcessTime : Date ;
247- if ( nextStartEvent && nextEndTime ) {
248- nextProcessTime = nextStartEvent . startTime . getTime ( ) < nextEndTime . getTime ( ) ? nextStartEvent . startTime : nextEndTime ;
249- } else if ( nextStartEvent ) {
250- nextProcessTime = nextStartEvent . startTime ;
251- } else if ( nextEndTime ) {
252- nextProcessTime = nextEndTime ;
253- } else {
254- // This should never happen due to the earlier check, but just in case
232+ const nextStartEvent = await CalendarEvent . findNextFutureEvent ( new Date ( ) ) ;
233+ if ( ! nextStartEvent ) {
255234 return ;
256235 }
257236
258- await cronJobs . addAtTimestamp ( schedulerJobId , nextProcessTime , async ( ) => this . processStatusChangesAtTime ( ) ) ;
237+ await cronJobs . addAtTimestamp ( schedulerJobId , nextStartEvent . startTime , async ( ) => this . processStatusChangesAtTime ( ) ) ;
259238 }
260239
261240 private async processStatusChangesAtTime ( ) : Promise < void > {
@@ -269,75 +248,74 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe
269248 await this . processEventStart ( event ) ;
270249 }
271250
272- const eventsEndingNow = await CalendarEvent . findEventsEndingNow ( { now : processTime , offset : 5000 } ) . toArray ( ) ;
273- for await ( const event of eventsEndingNow ) {
274- if ( event . busy === false ) {
275- continue ;
276- }
277- await this . processEventEnd ( event ) ;
278- }
279-
280251 await this . doSetupNextStatusChange ( ) ;
281252 }
282253
283254 private async processEventStart ( event : ICalendarEvent ) : Promise < void > {
255+ // Without an endTime there is no expiry to set, so the claim could never auto-restore.
284256 if ( ! event . endTime ) {
285257 return ;
286258 }
287259
288- const user = await Users . findOneById ( event . uid , { projection : { statusDefault : 1 } } ) ;
289- if ( ! user || user . statusDefault === UserStatus . OFFLINE ) {
260+ await this . syncBusyPresence ( event . uid , { excludeEventId : event . _id , seedEndTime : event . endTime } ) ;
261+ }
262+
263+ // The start scheduler only fires at event start times, so it never reacts to a (re)imported event
264+ // that is already in progress — whether newly imported mid-meeting or one whose end time changed on
265+ // resync. This brings the user's presence in line with the current state of such an event.
266+ private async reconcileInProgressEvent ( eventId : ICalendarEvent [ '_id' ] ) : Promise < void > {
267+ const event = await CalendarEvent . findOne ( { _id : eventId } ) ;
268+ if ( ! event ?. endTime || event . busy === false ) {
290269 return ;
291270 }
292271
293- const overlappingEvents = await CalendarEvent . findOverlappingEvents ( event . _id , event . uid , event . startTime , event . endTime )
294- . sort ( { startTime : - 1 } )
295- . toArray ( ) ;
296- const previousStatus = overlappingEvents . at ( 0 ) ?. previousStatus ?? user . statusDefault ;
297-
298- if ( previousStatus ) {
299- await CalendarEvent . updateEvent ( event . _id , { previousStatus } ) ;
272+ const now = new Date ( ) ;
273+ if ( event . startTime > now || event . endTime <= now ) {
274+ return ;
300275 }
301276
302- await applyStatusChange ( {
303- eventId : event . _id ,
304- uid : event . uid ,
305- startTime : event . startTime ,
306- endTime : event . endTime ,
307- status : UserStatus . BUSY ,
308- } ) ;
277+ await this . syncBusyPresence ( event . uid , { excludeEventId : event . _id , seedEndTime : event . endTime , now } ) ;
309278 }
310279
311- private async processEventEnd ( event : ICalendarEvent ) : Promise < void > {
312- if ( ! event . endTime ) {
280+ // Single authority for a user's calendar busy presence. Derives "busy until the latest active
281+ // meeting ends" from the events in progress right now and applies it:
282+ // - no active event -> release our claim (no-op if a higher-priority source owns it)
283+ // - already cal-busy -> refresh the expiry in place (never re-stashes previousState)
284+ // - otherwise -> apply a fresh claim (engine precedence decides if it wins)
285+ // `excludeEventId` is left out of the lookup; `seedEndTime` adds back the triggering event's own end
286+ // (used by start/reconcile, where findOverlappingEvents excludes that event).
287+ private async syncBusyPresence (
288+ uid : IUser [ '_id' ] ,
289+ { excludeEventId, seedEndTime, now = new Date ( ) } : { excludeEventId ?: ICalendarEvent [ '_id' ] ; seedEndTime ?: Date ; now ?: Date } = { } ,
290+ ) : Promise < void > {
291+ if ( ! settings . get < boolean > ( 'Calendar_BusyStatus_Enabled' ) ) {
313292 return ;
314293 }
315294
316- const user = await Users . findOneById ( event . uid , { projection : { statusDefault : 1 } } ) ;
317- if ( ! user ) {
295+ const overlappingEvents = await CalendarEvent . findOverlappingEvents ( excludeEventId ?? '' , uid , now , now ) . toArray ( ) ;
296+ const endTimes = [ ...( seedEndTime ? [ seedEndTime ] : [ ] ) , ...overlappingEvents . map ( ( event ) => event . endTime ) ] . filter (
297+ ( date ) : date is Date => Boolean ( date ) ,
298+ ) ;
299+
300+ // No busy event active anymore → end our claim. The source guard makes this a no-op if a
301+ // manual/voice status has taken over, so we never clobber a higher-priority status.
302+ if ( endTimes . length === 0 ) {
303+ await Presence . endActiveState ( uid , 'external' ) ;
318304 return ;
319305 }
320306
321- // Only restore status if:
322- // 1. The current statusDefault is BUSY (meaning it was set by our system, not manually changed by user)
323- // 2. We have a previousStatus stored from before the event started
324-
325- if ( user . statusDefault === UserStatus . BUSY && event . previousStatus && event . previousStatus !== user . statusDefault ) {
326- await applyStatusChange ( {
327- eventId : event . _id ,
328- uid : event . uid ,
329- startTime : event . startTime ,
330- endTime : event . endTime ,
331- status : event . previousStatus ,
332- } ) ;
333- } else {
334- logger . debug ( {
335- msg : 'Not restoring status for user' ,
336- userId : event . uid ,
337- currentStatusDefault : user . statusDefault ,
338- previousStatus : event . previousStatus ,
339- } ) ;
340- }
307+ // setActive is idempotent for the calendar's own claim: re-applying the same external source
308+ // updates the expiry in place (no re-stash), so we don't need to know whether we already own it.
309+ const statusExpiresAt = endTimes . reduce ( ( latest , date ) => ( date > latest ? date : latest ) ) ;
310+ const user = await Users . findOneById < Pick < IUser , '_id' | 'language' > > ( uid , { projection : { language : 1 } } ) ;
311+ const lng = user ?. language || settings . get < string > ( 'Language' ) || 'en' ;
312+
313+ await Presence . setActiveState ( uid , {
314+ statusDefault : UserStatus . BUSY ,
315+ statusText : i18n . t ( 'Presence_status_in_a_meeting' , { lng } ) ,
316+ statusSource : 'external' ,
317+ statusExpiresAt,
318+ } ) ;
341319 }
342320
343321 private async sendCurrentNotifications ( date : Date ) : Promise < void > {
0 commit comments