snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 1s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
import type { AtLeast, ILivechatAgentStatus, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import type { ILivechatBusinessHoursModel, IUsersModel } from '@rocket.chat/model-typings';
import { LivechatBusinessHours, Users } from '@rocket.chat/models';
import type { IWorkHoursCronJobsWrapper } from '@rocket.chat/models';
import moment from 'moment-timezone';
import type { UpdateFilter } from 'mongodb';
import { notifyOnUserChange } from '../../notifyListener';
export interface IBusinessHourBehavior {
findHoursToCreateJobs(): Promise<IWorkHoursCronJobsWrapper[]>;
openBusinessHoursByDayAndHour(day: string, hour: string): Promise<void>;
closeBusinessHoursByDayAndHour(day: string, hour: string): Promise<void>;
onDisableBusinessHours(): Promise<void>;
onAddAgentToDepartment(options?: { departmentId: string; agentsId: string[] }): Promise<any>;
onRemoveAgentFromDepartment(options?: Record<string, any>): Promise<any>;
onRemoveDepartment(options: { department: ILivechatDepartment; agentsIds: string[] }): Promise<any>;
onDepartmentDisabled(department?: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>): Promise<void>;
onDepartmentArchived(department: Pick<ILivechatDepartment, '_id' | 'businessHourId'>): Promise<void>;
onStartBusinessHours(): Promise<void>;
afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise<void>;
allowAgentChangeServiceStatus(agentId: string): Promise<boolean>;
changeAgentActiveStatus(agentId: string, status: string): Promise<any>;
// If a new agent is created, this callback will be called
onNewAgentCreated(agentId: string): Promise<void>;
}
export interface IBusinessHourType {
name: string;
getBusinessHour(id?: string): Promise<ILivechatBusinessHour | null>;
saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise<ILivechatBusinessHour>;
removeBusinessHourById(id: string): Promise<void>;
}
export abstract class AbstractBusinessHourBehavior {
protected BusinessHourRepository: ILivechatBusinessHoursModel = LivechatBusinessHours;
protected UsersRepository: IUsersModel = Users;
async findHoursToCreateJobs(): Promise<IWorkHoursCronJobsWrapper[]> {
return this.BusinessHourRepository.findHoursToScheduleJobs();
}
async onDisableBusinessHours(): Promise<void> {
await this.UsersRepository.removeBusinessHoursFromAllUsers();
}
async allowAgentChangeServiceStatus(agentId: string): Promise<boolean> {
return this.UsersRepository.isAgentWithinBusinessHours(agentId);
}
async changeAgentActiveStatus(agentId: string, status: ILivechatAgentStatus): Promise<any> {
const result = await this.UsersRepository.setLivechatStatusIf(
agentId,
status,
// Why this works: statusDefault is the property set when a user manually changes their status
// So if it's set to offline, we can be sure the user will be offline after login and we can skip the update
{ livechatStatusSystemModified: true, statusDefault: { $ne: UserStatus.OFFLINE } },
{ livechatStatusSystemModified: true },
);
if (result.modifiedCount > 0) {
void notifyOnUserChange({
clientAction: 'updated',
id: agentId,
diff: { statusLivechat: 'available', livechatStatusSystemModified: true },
});
}
return result;
}
}
export abstract class AbstractBusinessHourType {
protected BusinessHourRepository: ILivechatBusinessHoursModel = LivechatBusinessHours;
protected UsersRepository: IUsersModel = Users;
protected async baseSaveBusinessHour(businessHourData: ILivechatBusinessHour): Promise<string> {
businessHourData.active = Boolean(businessHourData.active);
businessHourData = this.convertWorkHours(businessHourData);
if (businessHourData._id) {
await this.BusinessHourRepository.updateOne({ _id: businessHourData._id }, {
$set: businessHourData,
} as UpdateFilter<ILivechatBusinessHour>); // TODO: Remove this cast when TypeScript is updated
return businessHourData._id;
}
const { insertedId } = await this.BusinessHourRepository.insertOne(businessHourData);
return insertedId;
}
private convertWorkHours(businessHourData: ILivechatBusinessHour): ILivechatBusinessHour {
businessHourData.workHours.forEach((hour: any) => {
const startUtc = moment.tz(`${hour.day}:${hour.start}`, 'dddd:HH:mm', businessHourData.timezone.name).utc();
const finishUtc = moment.tz(`${hour.day}:${hour.finish}`, 'dddd:HH:mm', businessHourData.timezone.name).utc();
if (hour.open && finishUtc.isBefore(startUtc)) {
throw new Error('error-business-hour-finish-time-before-start-time');
}
if (hour.open && startUtc.isSame(finishUtc)) {
throw new Error('error-business-hour-finish-time-equals-start-time');
}
hour.start = {
time: hour.start,
utc: {
dayOfWeek: startUtc.clone().format('dddd'),
time: startUtc.clone().format('HH:mm'),
},
cron: {
dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'dddd'),
time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'HH:mm'),
},
};
hour.finish = {
time: hour.finish,
utc: {
dayOfWeek: finishUtc.clone().format('dddd'),
time: finishUtc.clone().format('HH:mm'),
},
cron: {
dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'dddd'),
time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'HH:mm'),
},
};
});
return businessHourData;
}
protected getUTCFromTimezone(timezone?: string): string {
if (!timezone) {
return String(moment().utcOffset() / 60);
}
return moment.tz(timezone).format('Z');
}
private formatDayOfTheWeekFromServerTimezoneAndUtcHour(utc: any, format: string): string {
return moment(utc.format('dddd:HH:mm'), 'dddd:HH:mm')
.add(moment().utcOffset() / 60, 'hours')
.format(format);
}
}

View File

@@ -0,0 +1,306 @@
import type { ILivechatBusinessHour, IBusinessHourTimezone } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import type { AgendaCronJobs } from '@rocket.chat/cron';
import { LivechatBusinessHours, LivechatDepartment, Users } from '@rocket.chat/models';
import moment from 'moment-timezone';
import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour';
import { closeBusinessHour } from './closeBusinessHour';
import { settings } from '../../../settings';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { businessHourLogger } from '../logger';
const CRON_EVERY_MIDNIGHT_EXPRESSION = '0 0 * * *';
const CRON_DAYLIGHT_JOB_NAME = 'livechat-business-hour-daylight-saving-time-verifier';
export class BusinessHourManager {
private types: Map<string, IBusinessHourType> = new Map();
private behavior: IBusinessHourBehavior;
private cronJobs: AgendaCronJobs;
private cronJobsCache: string[] = [];
constructor(cronJobs: AgendaCronJobs) {
this.cronJobs = cronJobs;
this.openWorkHoursCallback = this.openWorkHoursCallback.bind(this);
this.closeWorkHoursCallback = this.closeWorkHoursCallback.bind(this);
}
async startManager(): Promise<void> {
await this.createCronJobsForWorkHours();
this.setupCallbacks();
await this.cleanupDisabledDepartmentReferences();
await this.behavior.onStartBusinessHours();
void this.startDaylightSavingTimeVerifier();
void this.registerDaylightSavingTimeCronJob();
}
async stopManager(): Promise<void> {
await this.removeCronJobs();
this.clearCronJobsCache();
this.removeCallbacks();
await this.behavior.onDisableBusinessHours();
await this.cronJobs.remove(CRON_DAYLIGHT_JOB_NAME);
}
async restartManager(): Promise<void> {
await this.stopManager();
await this.startManager();
}
async cleanupDisabledDepartmentReferences(): Promise<void> {
// Get business hours with departments enabled and disabled
const bhWithDepartments = await LivechatDepartment.getBusinessHoursWithDepartmentStatuses();
if (!bhWithDepartments.length) {
// If there are no bh, skip
return;
}
for await (const { _id: businessHourId, validDepartments, invalidDepartments } of bhWithDepartments) {
if (!invalidDepartments.length) {
continue;
}
// If there are no enabled departments, close the business hour
const allDepsAreDisabled = validDepartments.length === 0 && invalidDepartments.length > 0;
if (allDepsAreDisabled) {
const businessHour = await this.getBusinessHour(businessHourId, LivechatBusinessHourTypes.CUSTOM);
if (!businessHour) {
continue;
}
await closeBusinessHour(businessHour);
}
// Remove business hour from disabled departments
await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(invalidDepartments, businessHourId);
}
}
async allowAgentChangeServiceStatus(agentId: string): Promise<boolean> {
if (!settings.get('Livechat_enable_business_hours')) {
return true;
}
return this.behavior.allowAgentChangeServiceStatus(agentId);
}
registerBusinessHourType(businessHourType: IBusinessHourType): void {
this.types.set(businessHourType.name, businessHourType);
}
registerBusinessHourBehavior(behavior: IBusinessHourBehavior): void {
this.behavior = behavior;
}
async getBusinessHour(id?: string, type?: string): Promise<ILivechatBusinessHour | null> {
const businessHourType = this.getBusinessHourType((type as string) || LivechatBusinessHourTypes.DEFAULT);
if (!businessHourType) {
return null;
}
return businessHourType.getBusinessHour(id);
}
async saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise<void> {
const type = this.getBusinessHourType((businessHourData.type as string) || LivechatBusinessHourTypes.DEFAULT) as IBusinessHourType;
const saved = await type.saveBusinessHour(businessHourData);
if (!settings.get('Livechat_enable_business_hours')) {
return;
}
await this.behavior.afterSaveBusinessHours(saved);
await this.createCronJobsForWorkHours();
}
async removeBusinessHourByIdAndType(id: string, type: string): Promise<void> {
const businessHourType = this.getBusinessHourType(type) as IBusinessHourType;
await businessHourType.removeBusinessHourById(id);
if (!settings.get('Livechat_enable_business_hours')) {
return;
}
await this.createCronJobsForWorkHours();
}
async onLogin(agentId: string): Promise<any> {
if (!settings.get('Livechat_enable_business_hours')) {
return this.behavior.changeAgentActiveStatus(agentId, 'available');
}
const result = await Users.setLivechatStatusActiveBasedOnBusinessHours(agentId);
if (result.updatedCount > 0) {
void notifyOnUserChange({ clientAction: 'updated', id: agentId, diff: { statusLivechat: 'available ' } });
}
return result;
}
async restartCronJobsIfNecessary(): Promise<void> {
if (!settings.get('Livechat_enable_business_hours')) {
return;
}
await this.createCronJobsForWorkHours();
}
private setupCallbacks(): void {
callbacks.add(
'livechat.removeAgentDepartment',
this.behavior.onRemoveAgentFromDepartment.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-remove-agent-department',
);
callbacks.add(
'livechat.afterRemoveDepartment',
this.behavior.onRemoveDepartment.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-after-remove-department',
);
callbacks.add(
'livechat.saveAgentDepartment',
this.behavior.onAddAgentToDepartment.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-save-agent-department',
);
callbacks.add(
'livechat.afterDepartmentDisabled',
this.behavior.onDepartmentDisabled.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-department-disabled',
);
callbacks.add(
'livechat.afterDepartmentArchived',
this.behavior.onDepartmentArchived.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-department-archived',
);
callbacks.add(
'livechat.onNewAgentCreated',
this.behavior.onNewAgentCreated.bind(this),
callbacks.priority.HIGH,
'business-hour-livechat-on-agent-created',
);
}
private removeCallbacks(): void {
callbacks.remove('livechat.removeAgentDepartment', 'business-hour-livechat-on-remove-agent-department');
callbacks.remove('livechat.afterRemoveDepartment', 'business-hour-livechat-after-remove-department');
callbacks.remove('livechat.saveAgentDepartment', 'business-hour-livechat-on-save-agent-department');
callbacks.remove('livechat.afterDepartmentDisabled', 'business-hour-livechat-on-department-disabled');
callbacks.remove('livechat.afterDepartmentArchived', 'business-hour-livechat-on-department-archived');
callbacks.remove('livechat.onNewAgentCreated', 'business-hour-livechat-on-agent-created');
}
private async createCronJobsForWorkHours(): Promise<void> {
await this.removeCronJobs();
this.clearCronJobsCache();
const [workHours] = await this.behavior.findHoursToCreateJobs();
if (!workHours) {
return;
}
const { start, finish } = workHours;
await Promise.all(start.map(({ day, times }) => this.scheduleCronJob(times, day, 'open', this.openWorkHoursCallback)));
await Promise.all(finish.map(({ day, times }) => this.scheduleCronJob(times, day, 'close', this.closeWorkHoursCallback)));
}
private async scheduleCronJob(
items: string[],
day: string,
type: 'open' | 'close',
job: (day: string, hour: string) => void,
): Promise<void> {
await Promise.all(
items.map((hour) => {
const time = moment(hour, 'HH:mm').day(day);
const jobName = `${time.format('dddd')}/${time.format('HH:mm')}/${type}`;
const scheduleAt = `${time.minutes()} ${time.hours()} * * ${time.day()}`;
this.addToCache(jobName);
return this.cronJobs.add(jobName, scheduleAt, () => job(day, hour));
}),
);
}
private async openWorkHoursCallback(day: string, hour: string): Promise<void> {
try {
return await this.behavior.openBusinessHoursByDayAndHour(day, hour);
} catch (err) {
businessHourLogger.error({ msg: 'Error while opening business hours', err });
throw err;
}
}
private async closeWorkHoursCallback(day: string, hour: string): Promise<void> {
try {
return await this.behavior.closeBusinessHoursByDayAndHour(day, hour);
} catch (err) {
businessHourLogger.error({ msg: 'Error while closing business hours', err });
throw err;
}
}
private getBusinessHourType(type: string): IBusinessHourType | undefined {
return this.types.get(type);
}
private async removeCronJobs(): Promise<void> {
await Promise.all(this.cronJobsCache.map((jobName) => this.cronJobs.remove(jobName)));
}
private addToCache(jobName: string): void {
this.cronJobsCache.push(jobName);
}
private clearCronJobsCache(): void {
this.cronJobsCache = [];
}
hasDaylightSavingTimeChanged(timezone: IBusinessHourTimezone): boolean {
const now = moment().utc().tz(timezone.name);
const currentUTC = now.format('Z');
const existingTimezoneUTC = moment(timezone.utc, 'Z').utc().tz(timezone.name);
const DSTHasChanged = !moment(currentUTC, 'Z').utc().tz(timezone.name).isSame(existingTimezoneUTC);
return currentUTC !== timezone.utc && DSTHasChanged;
}
async registerDaylightSavingTimeCronJob(): Promise<void> {
await this.cronJobs.add(CRON_DAYLIGHT_JOB_NAME, CRON_EVERY_MIDNIGHT_EXPRESSION, this.startDaylightSavingTimeVerifier.bind(this));
}
async startDaylightSavingTimeVerifier(): Promise<void> {
const activeBusinessHours = await LivechatBusinessHours.findActiveBusinessHours();
const timezonesNeedingAdjustment = activeBusinessHours.filter(
({ timezone }) => timezone && this.hasDaylightSavingTimeChanged(timezone),
);
if (timezonesNeedingAdjustment.length === 0) {
return;
}
const result = await Promise.allSettled(
timezonesNeedingAdjustment.map((businessHour) => {
const businessHourType = this.getBusinessHourType(businessHour.type);
if (!businessHourType) {
return;
}
return businessHourType.saveBusinessHour({
...businessHour,
timezoneName: businessHour.timezone.name,
workHours: businessHour.workHours.map((hour) => ({ ...hour, start: hour.start.time, finish: hour.finish.time })) as Record<
string,
any
>[],
} as ILivechatBusinessHour & { timezoneName: string });
}),
);
const failed = result.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
failed.forEach((error: any) => {
businessHourLogger.error({ msg: 'Failed to update business hours with new timezone', err: error });
});
}
await this.createCronJobsForWorkHours();
}
}

View File

@@ -0,0 +1,36 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import moment from 'moment-timezone';
import type { IBusinessHourType } from './AbstractBusinessHour';
import { AbstractBusinessHourType } from './AbstractBusinessHour';
interface IExtraProperties {
timezoneName?: string;
}
export class DefaultBusinessHour extends AbstractBusinessHourType implements IBusinessHourType {
name = LivechatBusinessHourTypes.DEFAULT;
getBusinessHour(): Promise<ILivechatBusinessHour | null> {
return this.BusinessHourRepository.findOneDefaultBusinessHour();
}
async saveBusinessHour(businessHour: ILivechatBusinessHour & IExtraProperties): Promise<ILivechatBusinessHour> {
const { timezoneName, ...businessHourData } = businessHour;
if (!businessHourData._id) {
return businessHourData;
}
businessHourData.timezone = {
name: timezoneName || moment.tz.guess(),
utc: this.getUTCFromTimezone(timezoneName),
};
await this.baseSaveBusinessHour(businessHourData);
return businessHourData;
}
removeBusinessHourById(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -0,0 +1,106 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { ILivechatAgentStatus, LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { LivechatBusinessHours, Users } from '@rocket.chat/models';
import moment from 'moment';
import { createDefaultBusinessHourRow } from './LivechatBusinessHours';
import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened';
import { notifyOnUserChangeAsync } from '../../notifyListener';
import { businessHourLogger } from '../logger';
export { filterBusinessHoursThatMustBeOpened };
export const filterBusinessHoursThatMustBeOpenedByDay = async (
businessHours: Pick<ILivechatBusinessHour, '_id' | 'type' | 'active' | 'workHours'>[],
day: string, // Format: moment.format('dddd')
): Promise<Pick<ILivechatBusinessHour, '_id' | 'type'>[]> => {
return filterBusinessHoursThatMustBeOpened(
businessHours.filter((businessHour) =>
businessHour.workHours.some((workHour) => workHour.start.utc.dayOfWeek === day || workHour.finish.utc.dayOfWeek === day),
),
);
};
export const openBusinessHourDefault = async (): Promise<void> => {
try {
await Users.removeBusinessHoursFromAllUsers();
const currentTime = moment(moment().format('dddd:HH:mm'), 'dddd:HH:mm');
const day = currentTime.format('dddd');
const activeBusinessHours = await LivechatBusinessHours.findDefaultActiveAndOpenBusinessHoursByDay(day, {
projection: {
workHours: 1,
timezone: 1,
type: 1,
active: 1,
},
});
const businessHoursToOpenIds = (await filterBusinessHoursThatMustBeOpened(activeBusinessHours)).map((businessHour) => businessHour._id);
businessHourLogger.debug({ msg: 'Opening default business hours', businessHoursToOpenIds });
await Users.openAgentsBusinessHoursByBusinessHourId(businessHoursToOpenIds);
if (businessHoursToOpenIds.length) {
await makeOnlineAgentsAvailable();
}
await makeAgentsUnavailableBasedOnBusinessHour();
} catch (err) {
businessHourLogger.error({ msg: 'Error while opening default business hours', err });
}
};
export const createDefaultBusinessHourIfNotExists = async (): Promise<void> => {
if ((await LivechatBusinessHours.countDocuments({ type: LivechatBusinessHourTypes.DEFAULT })) === 0) {
await LivechatBusinessHours.insertOne(createDefaultBusinessHourRow());
}
};
export async function makeAgentsUnavailableBasedOnBusinessHour(agentIds?: string[]) {
const results = await Users.findAgentsAvailableWithoutBusinessHours(agentIds).toArray();
const update = await Users.updateLivechatStatusByAgentIds(
results.map(({ _id }) => _id),
ILivechatAgentStatus.NOT_AVAILABLE,
);
if (update.modifiedCount === 0) {
return;
}
void notifyOnUserChangeAsync(async () =>
results.map(({ _id, openBusinessHours }) => {
return {
id: _id,
clientAction: 'updated',
diff: {
statusLivechat: 'not-available',
openBusinessHours,
},
};
}),
);
}
export async function makeOnlineAgentsAvailable(agentIds?: string[]) {
const results = await Users.findOnlineButNotAvailableAgents(agentIds).toArray();
const update = await Users.updateLivechatStatusByAgentIds(
results.map(({ _id }) => _id),
ILivechatAgentStatus.AVAILABLE,
);
if (update.modifiedCount === 0) {
return;
}
void notifyOnUserChangeAsync(async () =>
results.map(({ _id, openBusinessHours }) => {
return {
id: _id,
clientAction: 'updated',
diff: {
statusLivechat: 'available',
openBusinessHours,
},
};
}),
);
}

View File

@@ -0,0 +1,47 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
export const createDefaultBusinessHourRow = (): Omit<ILivechatBusinessHour, '_updatedAt'> => {
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const closedDays = ['Saturday', 'Sunday'];
return {
_id: new ObjectId().toHexString(),
name: '',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
ts: new Date(),
workHours: days.map((day, index) => ({
day,
start: {
time: '08:00',
utc: {
dayOfWeek: moment(`${day}:08:00`, 'dddd:HH:mm').utc().format('dddd'),
time: moment(`${day}:08:00`, 'dddd:HH:mm').utc().format('HH:mm'),
},
cron: {
dayOfWeek: moment(`${day}:08:00`, 'dddd:HH:mm').format('dddd'),
time: moment(`${day}:08:00`, 'dddd:HH:mm').format('HH:mm'),
},
},
finish: {
time: '20:00',
utc: {
dayOfWeek: moment(`${day}:20:00`, 'dddd:HH:mm').utc().format('dddd'),
time: moment(`${day}:20:00`, 'dddd:HH:mm').utc().format('HH:mm'),
},
cron: {
dayOfWeek: moment(`${day}:20:00`, 'dddd:HH:mm').format('dddd'),
time: moment(`${day}:20:00`, 'dddd:HH:mm').format('HH:mm'),
},
},
code: index + 1,
open: !closedDays.includes(day),
})),
timezone: {
name: moment.tz.guess(),
utc: String(moment().utcOffset() / 60),
},
};
};

View File

@@ -0,0 +1,99 @@
import { ILivechatAgentStatus, LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { LivechatBusinessHours, Users } from '@rocket.chat/models';
import type { IBusinessHourBehavior } from './AbstractBusinessHour';
import { AbstractBusinessHourBehavior } from './AbstractBusinessHour';
import { filterBusinessHoursThatMustBeOpened, makeAgentsUnavailableBasedOnBusinessHour, openBusinessHourDefault } from './Helper';
import { notifyOnUserChange } from '../../notifyListener';
import { businessHourLogger } from '../logger';
export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior {
async openBusinessHoursByDayAndHour(): Promise<void> {
await openBusinessHourDefault();
}
async closeBusinessHoursByDayAndHour(day: string, hour: string): Promise<void> {
const businessHoursIds = (
await this.BusinessHourRepository.findActiveBusinessHoursToClose(day, hour, LivechatBusinessHourTypes.DEFAULT, {
projection: { _id: 1 },
})
).map((businessHour) => businessHour._id);
await this.UsersRepository.closeAgentsBusinessHoursByBusinessHourIds(businessHoursIds);
await makeAgentsUnavailableBasedOnBusinessHour();
}
async onStartBusinessHours(): Promise<void> {
await openBusinessHourDefault();
}
async onNewAgentCreated(agentId: string): Promise<void> {
const defaultBusinessHour = await LivechatBusinessHours.findOneDefaultBusinessHour();
if (!defaultBusinessHour) {
businessHourLogger.debug({
msg: 'No default business hour found for agentId',
agentId,
});
return;
}
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]);
if (!businessHourToOpen.length) {
businessHourLogger.debug({
msg: 'No business hours found. Moving agent to NOT_AVAILABLE status',
agentId,
newStatus: ILivechatAgentStatus.NOT_AVAILABLE,
});
const { modifiedCount } = await Users.setLivechatStatus(agentId, ILivechatAgentStatus.NOT_AVAILABLE);
if (modifiedCount > 0) {
void notifyOnUserChange({
id: agentId,
clientAction: 'updated',
diff: {
statusLivechat: ILivechatAgentStatus.NOT_AVAILABLE,
livechatStatusSystemModified: false,
},
});
}
return;
}
await Users.addBusinessHourByAgentIds([agentId], defaultBusinessHour._id);
businessHourLogger.debug({
msg: 'Business hours found. Moving agent to AVAILABLE status',
agentId,
newStatus: ILivechatAgentStatus.AVAILABLE,
});
}
afterSaveBusinessHours(): Promise<void> {
return openBusinessHourDefault();
}
removeBusinessHourById(): Promise<void> {
return Promise.resolve();
}
onAddAgentToDepartment(): Promise<any> {
return Promise.resolve();
}
onRemoveAgentFromDepartment(): Promise<void> {
return Promise.resolve();
}
onRemoveDepartment(): Promise<void> {
return Promise.resolve();
}
onDepartmentDisabled(): Promise<void> {
return Promise.resolve();
}
onDepartmentArchived(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -0,0 +1,27 @@
import type { ILivechatBusinessHour, IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { makeFunction } from '@rocket.chat/patch-injection';
import { makeAgentsUnavailableBasedOnBusinessHour } from './Helper';
import { getAgentIdsForBusinessHour } from './getAgentIdsForBusinessHour';
import { businessHourLogger } from '../logger';
export const closeBusinessHourByAgentIds = async (
businessHourId: ILivechatBusinessHour['_id'],
agentIds: IUser['_id'][],
): Promise<void> => {
businessHourLogger.debug({
msg: 'Closing business hour',
businessHour: businessHourId,
totalAgents: agentIds.length,
top10AgentIds: agentIds.slice(0, 10),
});
await Users.removeBusinessHourByAgentIds(agentIds, businessHourId);
await makeAgentsUnavailableBasedOnBusinessHour();
};
export const closeBusinessHour = makeFunction(async (businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<void> => {
const agentIds = await getAgentIdsForBusinessHour();
return closeBusinessHourByAgentIds(businessHour._id, agentIds);
});

View File

@@ -0,0 +1,541 @@
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened';
describe('different timezones between server and business hours saturday ', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2024-04-20T20:10:11Z')));
afterEach(() => jest.useRealTimers());
it('should return a bh when the finish time resolves to a different day on server', async () => {
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '65c40fa9052d6750ae25df83',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Saturday',
time: '18:30',
},
cron: {
dayOfWeek: 'Saturday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Sunday',
time: '18:29',
},
cron: {
dayOfWeek: 'Sunday',
time: '15:29',
},
},
open: true,
code: '',
},
],
},
]);
expect(bh.length).toEqual(1);
});
});
describe('different timezones between server and business hours sunday ', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-07-27T11:02:11Z')));
afterEach(() => jest.useRealTimers());
it('should return a bh when the finish time resolves to a different day on server', async () => {
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '68516f256ebb4bdceda2757e',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Saturday',
time: '18:30',
},
cron: {
dayOfWeek: 'Saturday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Sunday',
time: '18:29',
},
cron: {
dayOfWeek: 'Sunday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Monday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Sunday',
time: '18:30',
},
cron: {
dayOfWeek: 'Sunday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Monday',
time: '18:29',
},
cron: {
dayOfWeek: 'Monday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Tuesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Monday',
time: '18:30',
},
cron: {
dayOfWeek: 'Monday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Tuesday',
time: '18:29',
},
cron: {
dayOfWeek: 'Tuesday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Wednesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Tuesday',
time: '18:30',
},
cron: {
dayOfWeek: 'Tuesday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Wednesday',
time: '18:29',
},
cron: {
dayOfWeek: 'Wednesday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Thursday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Wednesday',
time: '18:30',
},
cron: {
dayOfWeek: 'Wednesday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Thursday',
time: '18:29',
},
cron: {
dayOfWeek: 'Thursday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Friday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Thursday',
time: '18:30',
},
cron: {
dayOfWeek: 'Thursday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Friday',
time: '18:29',
},
cron: {
dayOfWeek: 'Friday',
time: '15:29',
},
},
open: true,
code: '',
},
{
day: 'Saturday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Friday',
time: '18:30',
},
cron: {
dayOfWeek: 'Friday',
time: '15:30',
},
},
finish: {
time: '23:59',
utc: {
dayOfWeek: 'Saturday',
time: '18:29',
},
cron: {
dayOfWeek: 'Saturday',
time: '15:29',
},
},
open: true,
code: '',
},
],
},
]);
expect(bh.length).toEqual(1);
});
});
describe('regular business hours', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(new Date('2025-08-07T22:02:11Z')));
afterEach(() => jest.useRealTimers());
it('should return a bh when the finish time resolves to a different day on server', async () => {
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '68516f256ebb4bdceda2757e',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Sunday',
time: '03:00',
},
cron: {
dayOfWeek: 'Sunday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Sunday',
time: '03:01',
},
cron: {
dayOfWeek: 'Sunday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Monday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Monday',
time: '03:00',
},
cron: {
dayOfWeek: 'Monday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Monday',
time: '03:01',
},
cron: {
dayOfWeek: 'Monday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Tuesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Tuesday',
time: '03:00',
},
cron: {
dayOfWeek: 'Tuesday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Tuesday',
time: '03:01',
},
cron: {
dayOfWeek: 'Tuesday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Wednesday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Wednesday',
time: '03:00',
},
cron: {
dayOfWeek: 'Wednesday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Wednesday',
time: '03:01',
},
cron: {
dayOfWeek: 'Wednesday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Thursday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Thursday',
time: '03:00',
},
cron: {
dayOfWeek: 'Thursday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Thursday',
time: '03:01',
},
cron: {
dayOfWeek: 'Thursday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Friday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Friday',
time: '03:00',
},
cron: {
dayOfWeek: 'Friday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Friday',
time: '03:01',
},
cron: {
dayOfWeek: 'Friday',
time: '00:01',
},
},
open: true,
code: '',
},
{
day: 'Saturday',
start: {
time: '00:00',
utc: {
dayOfWeek: 'Saturday',
time: '03:00',
},
cron: {
dayOfWeek: 'Saturday',
time: '00:00',
},
},
finish: {
time: '00:01',
utc: {
dayOfWeek: 'Saturday',
time: '03:01',
},
cron: {
dayOfWeek: 'Saturday',
time: '00:01',
},
},
open: true,
code: '',
},
],
},
]);
expect(bh.length).toEqual(0);
});
});
describe('finish time boundary', () => {
afterEach(() => jest.useRealTimers());
// 00:00-23:59 in Asia/Kolkata, which on a UTC server runs from Saturday 18:30 to Sunday 18:29
const alwaysOpenSunday = [
{
_id: '68516f256ebb4bdceda2757e',
name: '',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
ts: new Date(),
workHours: [
{
day: 'Sunday',
start: {
time: '00:00',
utc: { dayOfWeek: 'Saturday', time: '18:30' },
cron: { dayOfWeek: 'Saturday', time: '18:30' },
},
finish: {
time: '23:59',
utc: { dayOfWeek: 'Sunday', time: '18:29' },
cron: { dayOfWeek: 'Sunday', time: '18:29' },
},
open: true,
code: '',
},
],
timezone: {
name: 'Asia/Kolkata',
utc: '+05:30',
},
},
];
it('should keep the business hour open during its finish minute', async () => {
jest.useFakeTimers().setSystemTime(new Date('2025-07-27T18:29:30Z'));
expect(await filterBusinessHoursThatMustBeOpened(alwaysOpenSunday)).toHaveLength(1);
});
it('should close the business hour once the finish minute has elapsed', async () => {
jest.useFakeTimers().setSystemTime(new Date('2025-07-27T18:30:30Z'));
expect(await filterBusinessHoursThatMustBeOpened(alwaysOpenSunday)).toHaveLength(0);
});
it('should keep a same-day business hour open during its finish minute', async () => {
jest.useFakeTimers().setSystemTime(new Date('2025-07-28T20:00:30Z'));
const bh = await filterBusinessHoursThatMustBeOpened([
{
_id: '68516f256ebb4bdceda2757f',
active: true,
type: LivechatBusinessHourTypes.DEFAULT,
workHours: [
{
day: 'Monday',
start: {
time: '08:00',
utc: { dayOfWeek: 'Monday', time: '08:00' },
cron: { dayOfWeek: 'Monday', time: '08:00' },
},
finish: {
time: '20:00',
utc: { dayOfWeek: 'Monday', time: '20:00' },
cron: { dayOfWeek: 'Monday', time: '20:00' },
},
open: true,
code: '',
},
],
},
]);
expect(bh).toHaveLength(1);
});
});

View File

@@ -0,0 +1,58 @@
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
import moment from 'moment';
export const filterBusinessHoursThatMustBeOpened = async (
businessHours: Pick<ILivechatBusinessHour, '_id' | 'type' | 'active' | 'workHours'>[],
): Promise<Pick<ILivechatBusinessHour, '_id' | 'type'>[]> => {
const currentTime = moment(moment().format('dddd:HH:mm:ss'), 'dddd:HH:mm:ss');
return businessHours
.filter(
(businessHour) =>
businessHour.active &&
businessHour.workHours
.filter((hour) => hour.open)
.some((hour) => {
const localTimeStart = moment(`${hour.start.cron.dayOfWeek}:${hour.start.cron.time}:00`, 'dddd:HH:mm:ss');
const localTimeFinish = moment(`${hour.finish.cron.dayOfWeek}:${hour.finish.cron.time}:00`, 'dddd:HH:mm:ss');
/** because we use `dayOfWeek` moment decides if saturday/sunday belongs to the current week or the next one, this is a bit
* confusing and for that reason we need this workaround
*/
const currentDay = currentTime.format('dddd');
const localTimeStartDay = localTimeStart.format('dddd');
// This only works for sundays (where we can test if sunday is before saturday = something is wrong)
if (localTimeStart.isAfter(localTimeFinish)) {
localTimeStart.subtract(1, 'week');
}
if (localTimeFinish.isBefore(localTimeStart)) {
localTimeFinish.add(1, 'week');
}
// During Saturday, if current weekday is the same but the start time is after the current time, we need to subtract a week
if (currentDay === localTimeStartDay && localTimeStart.diff(currentTime, 'days') > 0) {
localTimeStart.subtract(1, 'week');
}
// During Saturday, if current weekday is the same but the finish time is before the current time, we need to add a week
if (currentDay === localTimeStartDay && localTimeFinish.diff(currentTime, 'days') < 0) {
localTimeFinish.add(1, 'week');
}
// Work hours are configured at minute granularity, so a window stays open until the end of its
// `finish` minute. Treating `finish` as exclusive leaves consecutive daily windows disjoint:
// a 00:00-23:59 business hour (the only way the UI can express "always open") would close
// for the whole 23:59 minute, every day.
const afterFinishMinute = localTimeFinish.clone().add(1, 'minute');
return currentTime.isSameOrAfter(localTimeStart) && currentTime.isBefore(afterFinishMinute);
}),
)
.map((businessHour) => ({
_id: businessHour._id,
type: businessHour.type,
}));
};

View File

@@ -0,0 +1,45 @@
import type { IUser } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
const getAllAgentIdsWithoutDepartment = async (): Promise<string[]> => {
// Fetch departments with agents excluding archived ones (disabled ones still can be tied to business hours)
// Then find the agents that are not in any of those departments
const departmentIds = (await LivechatDepartment.findNotArchived({ projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);
const agentIdsWithDepartment = await LivechatDepartmentAgents.findAllAgentsConnectedToListOfDepartments(departmentIds);
const agentIdsWithoutDepartment = (
await Users.findUsersInRolesWithQuery(
'livechat-agent',
{
_id: { $nin: agentIdsWithDepartment },
},
{ projection: { _id: 1 } },
).toArray()
).map((user) => user._id);
return agentIdsWithoutDepartment;
};
const getAllAgentIdsWithDepartmentNotConnectedToBusinessHour = async (): Promise<string[]> => {
const activeDepartmentsWithoutBusinessHour = (
await LivechatDepartment.findActiveDepartmentsWithoutBusinessHour({
projection: { _id: 1 },
}).toArray()
).map((dept) => dept._id);
const agentIdsWithDepartmentNotConnectedToBusinessHour = await LivechatDepartmentAgents.findAllAgentsConnectedToListOfDepartments(
activeDepartmentsWithoutBusinessHour,
);
return agentIdsWithDepartmentNotConnectedToBusinessHour;
};
export const getAgentIdsForBusinessHour = async (): Promise<IUser['_id'][]> => {
const [withoutDepartment, withDepartmentNotConnectedToBusinessHour] = await Promise.all([
getAllAgentIdsWithoutDepartment(),
getAllAgentIdsWithDepartmentNotConnectedToBusinessHour(),
]);
return [...new Set([...withoutDepartment, ...withDepartmentNotConnectedToBusinessHour])];
};

View File

@@ -0,0 +1,23 @@
import type { IUser } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { BusinessHourManager } from './BusinessHourManager';
import { DefaultBusinessHour } from './Default';
import { SingleBusinessHourBehavior } from './Single';
import { callbacks } from '../../callbacks';
export const businessHourManager = new BusinessHourManager(cronJobs);
Meteor.startup(async () => {
const { BusinessHourBehaviorClass } = await callbacks.run('on-business-hour-start', {
BusinessHourBehaviorClass: SingleBusinessHourBehavior,
});
businessHourManager.registerBusinessHourBehavior(new BusinessHourBehaviorClass());
businessHourManager.registerBusinessHourType(new DefaultBusinessHour());
Accounts.onLogin(({ user }: { user: IUser }) => {
void (user?.roles?.includes('livechat-agent') && !user?.roles?.includes('bot') && businessHourManager.onLogin(user._id));
});
});