feat(recurring-tasks): implement recurring tasks service with timezone support and notifications
- Added a new service for managing recurring tasks, allowing configuration of task schedules with timezone support. - Introduced job queues for processing recurring tasks and handling task creation in bulk. - Implemented notification system to alert users about newly created recurring tasks, including email and in-app notifications. - Enhanced database schema with new tables for notifications and audit logs to track recurring task operations. - Updated frontend components to support timezone selection and manage excluded dates for recurring tasks. - Refactored existing code to integrate new features and improve overall task management experience.
This commit is contained in:
156
worklenz-backend/src/utils/timezone-utils.ts
Normal file
156
worklenz-backend/src/utils/timezone-utils.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import moment from "moment-timezone";
|
||||
import { IRecurringSchedule } from "../interfaces/recurring-tasks";
|
||||
|
||||
export class TimezoneUtils {
|
||||
/**
|
||||
* Convert a date from one timezone to another
|
||||
*/
|
||||
static convertTimezone(date: moment.Moment | Date | string, fromTz: string, toTz: string): moment.Moment {
|
||||
return moment.tz(date, fromTz).tz(toTz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current time in a specific timezone
|
||||
*/
|
||||
static nowInTimezone(timezone: string): moment.Moment {
|
||||
return moment.tz(timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a recurring task should run based on timezone
|
||||
*/
|
||||
static shouldRunInTimezone(schedule: IRecurringSchedule, timezone: string): boolean {
|
||||
const now = this.nowInTimezone(timezone);
|
||||
const scheduleTime = moment.tz(schedule.created_at, timezone);
|
||||
|
||||
// Check if it's the right time of day (within a 1-hour window)
|
||||
const hourDiff = Math.abs(now.hour() - scheduleTime.hour());
|
||||
return hourDiff < 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next end date considering timezone
|
||||
*/
|
||||
static calculateNextEndDateWithTimezone(
|
||||
schedule: IRecurringSchedule,
|
||||
lastDate: moment.Moment | Date | string,
|
||||
timezone: string
|
||||
): moment.Moment {
|
||||
const lastMoment = moment.tz(lastDate, timezone);
|
||||
|
||||
switch (schedule.schedule_type) {
|
||||
case "daily":
|
||||
return lastMoment.clone().add(1, "day");
|
||||
|
||||
case "weekly":
|
||||
if (schedule.days_of_week && schedule.days_of_week.length > 0) {
|
||||
// Find next occurrence based on selected days
|
||||
let nextDate = lastMoment.clone();
|
||||
let daysChecked = 0;
|
||||
|
||||
do {
|
||||
nextDate.add(1, "day");
|
||||
daysChecked++;
|
||||
if (schedule.days_of_week.includes(nextDate.day())) {
|
||||
return nextDate;
|
||||
}
|
||||
} while (daysChecked < 7);
|
||||
|
||||
// If no valid day found, return next week's first selected day
|
||||
const sortedDays = [...schedule.days_of_week].sort((a, b) => a - b);
|
||||
nextDate = lastMoment.clone().add(1, "week").day(sortedDays[0]);
|
||||
return nextDate;
|
||||
}
|
||||
return lastMoment.clone().add(1, "week");
|
||||
|
||||
case "monthly":
|
||||
if (schedule.date_of_month) {
|
||||
// Specific date of month
|
||||
let nextDate = lastMoment.clone().add(1, "month").date(schedule.date_of_month);
|
||||
|
||||
// Handle months with fewer days
|
||||
if (nextDate.date() !== schedule.date_of_month) {
|
||||
nextDate = nextDate.endOf("month");
|
||||
}
|
||||
|
||||
return nextDate;
|
||||
} else if (schedule.week_of_month && schedule.day_of_month !== undefined) {
|
||||
// Nth occurrence of a day in month
|
||||
const nextMonth = lastMoment.clone().add(1, "month").startOf("month");
|
||||
const targetDay = schedule.day_of_month;
|
||||
const targetWeek = schedule.week_of_month;
|
||||
|
||||
// Find first occurrence of the target day
|
||||
let firstOccurrence = nextMonth.clone();
|
||||
while (firstOccurrence.day() !== targetDay) {
|
||||
firstOccurrence.add(1, "day");
|
||||
}
|
||||
|
||||
// Calculate nth occurrence
|
||||
if (targetWeek === 5) {
|
||||
// Last occurrence
|
||||
let lastOccurrence = firstOccurrence.clone();
|
||||
let temp = firstOccurrence.clone().add(7, "days");
|
||||
|
||||
while (temp.month() === nextMonth.month()) {
|
||||
lastOccurrence = temp.clone();
|
||||
temp.add(7, "days");
|
||||
}
|
||||
|
||||
return lastOccurrence;
|
||||
} else {
|
||||
// Specific week number
|
||||
return firstOccurrence.add((targetWeek - 1) * 7, "days");
|
||||
}
|
||||
}
|
||||
return lastMoment.clone().add(1, "month");
|
||||
|
||||
case "every_x_days":
|
||||
return lastMoment.clone().add(schedule.interval_days || 1, "days");
|
||||
|
||||
case "every_x_weeks":
|
||||
return lastMoment.clone().add(schedule.interval_weeks || 1, "weeks");
|
||||
|
||||
case "every_x_months":
|
||||
return lastMoment.clone().add(schedule.interval_months || 1, "months");
|
||||
|
||||
default:
|
||||
return lastMoment.clone().add(1, "day");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all timezones that should be processed in the current hour
|
||||
*/
|
||||
static getActiveTimezones(): string[] {
|
||||
const activeTimezones: string[] = [];
|
||||
const allTimezones = moment.tz.names();
|
||||
|
||||
for (const tz of allTimezones) {
|
||||
const tzTime = moment.tz(tz);
|
||||
// Check if it's 11:00 AM in this timezone (matching the cron schedule)
|
||||
if (tzTime.hour() === 11) {
|
||||
activeTimezones.push(tz);
|
||||
}
|
||||
}
|
||||
|
||||
return activeTimezones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate timezone string
|
||||
*/
|
||||
static isValidTimezone(timezone: string): boolean {
|
||||
return moment.tz.zone(timezone) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's timezone or default to UTC
|
||||
*/
|
||||
static getUserTimezone(userTimezone?: string): string {
|
||||
if (userTimezone && this.isValidTimezone(userTimezone)) {
|
||||
return userTimezone;
|
||||
}
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user