diff --git a/.changeset/thick-hotels-occur.md b/.changeset/thick-hotels-occur.md new file mode 100644 index 00000000000..22ab272bfad --- /dev/null +++ b/.changeset/thick-hotels-occur.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +fixes an issue where bussines hours are not working on weekends when the timezone of bh slip into another day diff --git a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts index 7255b3b0379..6fe3c1e2c9b 100644 --- a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts +++ b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.spec.ts @@ -2,7 +2,7 @@ import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import { filterBusinessHoursThatMustBeOpened } from './filterBusinessHoursThatMustBeOpened'; -describe('different timezones between server and business hours', () => { +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 () => { @@ -52,3 +52,429 @@ describe('different timezones between server and business hours', () => { 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, + ts: new Date(), + name: '', + 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: '', + }, + ], + timezone: { + name: 'Asia/Kolkata', + utc: '+05:30', + }, + }, + ]); + + 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, + ts: new Date(), + name: '', + 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: '', + }, + ], + timezone: { + name: 'America/Sao_Paulo', + utc: '-3', + }, + }, + ]); + + expect(bh.length).toEqual(0); + }); +}); diff --git a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts index c06dbaac4e1..fb379eb9b66 100644 --- a/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts +++ b/apps/meteor/app/livechat/server/business-hour/filterBusinessHoursThatMustBeOpened.ts @@ -16,11 +16,32 @@ export const filterBusinessHoursThatMustBeOpened = async ( 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'); - // The way we create the instances sunday will be the first day of the current week not the next one, that way it will never met isBefore + /** 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'); + } + return currentTime.isSameOrAfter(localTimeStart) && currentTime.isBefore(localTimeFinish); }), ) diff --git a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts index c65345402b5..3ccd19dd7d5 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/01-agents.ts @@ -570,8 +570,12 @@ describe('LIVECHAT - Agents', () => { }); describe('with no manage-agent permission', () => { before(async () => { + await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ status: 'not-available', agentId: agent2.user._id }) + .expect(200); await removePermissionFromAllRoles('manage-livechat-agents'); - console.log('Permissions removed'); }); after(async () => { await restorePermissionToRoles('manage-livechat-agents'); @@ -624,69 +628,80 @@ describe('LIVECHAT - Agents', () => { expect(res.body).to.have.property('error', 'Agent not found'); }); }); - it('should change logged in users status', async () => { - const currentUser: ILivechatAgent = await getMe(agent2.credentials); - const currentStatus = currentUser.statusLivechat; - const newStatus = currentStatus === 'available' ? 'not-available' : 'available'; - await request - .post(api('livechat/agent.status')) - .set(agent2.credentials) - .send({ status: newStatus, agentId: currentUser._id }) - .expect(200) - .expect((res: Response) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('status', newStatus); - }); - }); - it('should allow managers to change other agents status', async () => { - const currentUser: ILivechatAgent = await getMe(agent2.credentials); - const currentStatus = currentUser.statusLivechat; - const newStatus = currentStatus === 'available' ? 'not-available' : 'available'; + describe('cases for valid agents', () => { + let currentUser: ILivechatAgent; + before(async () => { + currentUser = await getMe(agent2.credentials); + }); - await request - .post(api('livechat/agent.status')) - .set(credentials) - .send({ status: newStatus, agentId: currentUser._id }) - .expect(200) - .expect((res: Response) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('status', newStatus); - }); - }); - it('should throw an error if agent tries to make themselves available outside of Business hour', async () => { - await makeDefaultBusinessHourActiveAndClosed(); + afterEach(async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'not-available', agentId: currentUser._id }) + .expect(200); + }); - const currentUser: ILivechatAgent = await getMe(agent2.credentials); - const currentStatus = currentUser.statusLivechat; - const newStatus = currentStatus === 'available' ? 'not-available' : 'available'; + it('should be able to set a logged in users status to available', async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('status', 'available'); + }); + }); + it('should allow managers to set other agents status to available', async () => { + await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('status', 'available'); + }); + }); + describe('outside of business hours', () => { + before(async () => { + await makeDefaultBusinessHourActiveAndClosed(); - await request - .post(api('livechat/agent.status')) - .set(agent2.credentials) - .send({ status: newStatus, agentId: currentUser._id }) - .expect(400) - .expect((res: Response) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'error-business-hours-are-closed'); + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'not-available', agentId: currentUser._id }) + .expect(200); }); - }); - it('should not allow managers to make other agents available outside business hour', async () => { - const currentUser: ILivechatAgent = await getMe(agent2.credentials); - const currentStatus = currentUser.statusLivechat; - const newStatus = currentStatus === 'available' ? 'not-available' : 'available'; - await request - .post(api('livechat/agent.status')) - .set(credentials) - .send({ status: newStatus, agentId: currentUser._id }) - .expect(200) - .expect((res: Response) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('status', currentStatus); + after(async () => { + await disableDefaultBusinessHour(); }); - - await disableDefaultBusinessHour(); + it('should throw an error if agent tries to make themselves available outside of Business hour', async () => { + await request + .post(api('livechat/agent.status')) + .set(agent2.credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-business-hours-are-closed'); + }); + }); + it('should return success but not change the agent when admin make try other agents available outside business hour', async () => { + await request + .post(api('livechat/agent.status')) + .set(credentials) + .send({ status: 'available', agentId: currentUser._id }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('status', 'not-available'); + }); + }); + }); }); });