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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
36
apps/meteor/server/lib/omnichannel/business-hour/Default.ts
Normal file
36
apps/meteor/server/lib/omnichannel/business-hour/Default.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
106
apps/meteor/server/lib/omnichannel/business-hour/Helper.ts
Normal file
106
apps/meteor/server/lib/omnichannel/business-hour/Helper.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
99
apps/meteor/server/lib/omnichannel/business-hour/Single.ts
Normal file
99
apps/meteor/server/lib/omnichannel/business-hour/Single.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
@@ -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])];
|
||||
};
|
||||
23
apps/meteor/server/lib/omnichannel/business-hour/index.ts
Normal file
23
apps/meteor/server/lib/omnichannel/business-hour/index.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user