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
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|