420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
/**
|
|
* OData helper functions for Microsoft Graph API
|
|
*/
|
|
const config = require('../config');
|
|
const { resolveTimeZone } = require('./timezone-mapper');
|
|
|
|
/**
|
|
* Returns the effective IANA timezone for date math.
|
|
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
|
|
* @returns {string}
|
|
*/
|
|
function getEffectiveTimeZone(timeZone = config.DEFAULT_TIMEZONE) {
|
|
return resolveTimeZone(timeZone);
|
|
}
|
|
|
|
/**
|
|
* Parses a formatted date string from Intl.DateTimeFormat into numeric components.
|
|
* @param {Date} date - UTC Date
|
|
* @param {string} timeZone - IANA timezone name
|
|
* @param {string[]} partsNeeded - parts to include
|
|
* @returns {Object}
|
|
*/
|
|
function formatParts(date, timeZone, partsNeeded = ['year', 'month', 'day']) {
|
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
timeZone,
|
|
year: 'numeric',
|
|
month: 'numeric',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: 'numeric',
|
|
second: 'numeric',
|
|
hour12: false
|
|
});
|
|
const allParts = fmt.formatToParts(date).reduce((acc, p) => {
|
|
acc[p.type] = p.value;
|
|
return acc;
|
|
}, {});
|
|
const result = {};
|
|
for (const key of partsNeeded) {
|
|
result[key] = parseInt(allParts[key], 10);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Returns the local (wall-clock) year/month/day for a UTC Date in the target timezone.
|
|
* @param {Date} date - UTC Date
|
|
* @param {string} timeZone - IANA timezone name
|
|
* @returns {{year:number, month:number, day:number}}
|
|
*/
|
|
function getLocalDateComponents(date, timeZone) {
|
|
if (!timeZone || timeZone === 'UTC') {
|
|
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };
|
|
}
|
|
return formatParts(date, timeZone, ['year', 'month', 'day']);
|
|
}
|
|
|
|
/**
|
|
* Computes the offset in milliseconds between UTC and the target timezone at a given UTC instant.
|
|
* Positive offset means the timezone is ahead of UTC.
|
|
* @param {Date} date - UTC Date
|
|
* @param {string} timeZone - IANA timezone name
|
|
* @returns {number} offset in ms
|
|
*/
|
|
function getTimeZoneOffsetMs(date, timeZone) {
|
|
if (!timeZone || timeZone === 'UTC') return 0;
|
|
|
|
const utcParts = {
|
|
year: date.getUTCFullYear(),
|
|
month: date.getUTCMonth() + 1,
|
|
day: date.getUTCDate(),
|
|
hour: date.getUTCHours(),
|
|
minute: date.getUTCMinutes(),
|
|
second: date.getUTCSeconds()
|
|
};
|
|
const localParts = formatParts(date, timeZone, ['year', 'month', 'day', 'hour', 'minute', 'second']);
|
|
|
|
const utcMs = Date.UTC(
|
|
utcParts.year, utcParts.month - 1, utcParts.day,
|
|
utcParts.hour, utcParts.minute, utcParts.second, date.getUTCMilliseconds()
|
|
);
|
|
const localMs = Date.UTC(
|
|
localParts.year, localParts.month - 1, localParts.day,
|
|
localParts.hour, localParts.minute, localParts.second, date.getUTCMilliseconds()
|
|
);
|
|
|
|
return localMs - utcMs;
|
|
}
|
|
|
|
/**
|
|
* Convert a local date/time expressed in the target timezone into a UTC Date.
|
|
*
|
|
* @param {number} year - local year
|
|
* @param {number} month - local month (1-12)
|
|
* @param {number} day - local day
|
|
* @param {number} [hours=0] - local hour
|
|
* @param {number} [minutes=0] - local minute
|
|
* @param {number} [seconds=0] - local second
|
|
* @param {number} [ms=0] - local millisecond
|
|
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
|
|
* @returns {Date} UTC instant
|
|
*/
|
|
function localToUtc(year, month, day, hours = 0, minutes = 0, seconds = 0, ms = 0, timeZone = config.DEFAULT_TIMEZONE) {
|
|
const tz = getEffectiveTimeZone(timeZone);
|
|
|
|
// Desired wall-clock timestamp in the target timezone, expressed as ms since Unix epoch
|
|
// *as if* that wall-clock time were UTC. This gives us a starting guess.
|
|
const targetMs = Date.UTC(year, month - 1, day, hours, minutes, seconds, ms);
|
|
let guess = new Date(targetMs);
|
|
|
|
// Converge on the real UTC instant for that wall-clock time (handles DST changes).
|
|
// residual = how far the guess's local time is ahead of (+) / behind (-) the target local time.
|
|
for (let i = 0; i < 5; i++) {
|
|
const offsetMs = getTimeZoneOffsetMs(guess, tz);
|
|
const actualLocalMs = guess.getTime() + offsetMs;
|
|
const residual = actualLocalMs - targetMs;
|
|
if (residual === 0) {
|
|
return guess;
|
|
}
|
|
guess = new Date(guess.getTime() - residual);
|
|
}
|
|
return guess;
|
|
}
|
|
|
|
/**
|
|
* Gets UTC instant for start of the local day containing `date` in the configured timezone.
|
|
* @param {Date} date - The date
|
|
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
|
|
* @returns {Date} - UTC instant of start of day in that timezone
|
|
*/
|
|
function startOfDay(date, timeZone = config.DEFAULT_TIMEZONE) {
|
|
const tz = getEffectiveTimeZone(timeZone);
|
|
const { year, month, day } = getLocalDateComponents(date, tz);
|
|
return localToUtc(year, month, day, 0, 0, 0, 0, tz);
|
|
}
|
|
|
|
/**
|
|
* Gets UTC instant for end of the local day containing `date` in the configured timezone.
|
|
* @param {Date} date - The date
|
|
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
|
|
* @returns {Date} - UTC instant of end of day in that timezone
|
|
*/
|
|
function endOfDay(date, timeZone = config.DEFAULT_TIMEZONE) {
|
|
const tz = getEffectiveTimeZone(timeZone);
|
|
const { year, month, day } = getLocalDateComponents(date, tz);
|
|
return localToUtc(year, month, day, 23, 59, 59, 999, tz);
|
|
}
|
|
|
|
/**
|
|
* Escapes a string for use in OData queries
|
|
* @param {string} str - The string to escape
|
|
* @returns {string} - The escaped string
|
|
*/
|
|
function escapeODataString(str) {
|
|
if (!str) return str;
|
|
|
|
// Replace single quotes with double single quotes (OData escaping)
|
|
// And remove any special characters that could cause OData syntax errors
|
|
str = str.replace(/'/g, "''");
|
|
|
|
// Escape other potentially problematic characters
|
|
str = str.replace(/[\(\)\{\}\[\]\:\;\,\/\?\&\=\+\*\%\$\#\@\!\^]/g, '');
|
|
|
|
console.error(`Escaped OData string: '${str}'`);
|
|
return str;
|
|
}
|
|
|
|
/**
|
|
* Builds an OData filter from filter conditions
|
|
* @param {Array<string>} conditions - Array of filter conditions
|
|
* @returns {string} - Combined OData filter expression
|
|
*/
|
|
function buildODataFilter(conditions) {
|
|
if (!conditions || conditions.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
return conditions.join(' and ');
|
|
}
|
|
|
|
/**
|
|
* Parses date input (ISO string or relative date)
|
|
* @param {string} dateInput - Date string
|
|
* @returns {Date} - Parsed date
|
|
*/
|
|
function parseDate(dateInput) {
|
|
if (!dateInput) return null;
|
|
|
|
const now = new Date();
|
|
const today = new Date(now);
|
|
|
|
// Handle relative dates
|
|
const relativeMap = {
|
|
'today': today,
|
|
'yesterday': new Date(now.getTime() - 24*60*60*1000),
|
|
'tomorrow': new Date(now.getTime() + 24*60*60*1000),
|
|
'last7days': new Date(now.getTime() - 7*24*60*60*1000),
|
|
'last30days': new Date(now.getTime() - 30*24*60*60*1000),
|
|
'last90days': new Date(now.getTime() - 90*24*60*60*1000)
|
|
};
|
|
|
|
if (relativeMap[dateInput.toLowerCase()]) {
|
|
return relativeMap[dateInput.toLowerCase()];
|
|
}
|
|
|
|
// Handle ISO dates
|
|
const parsed = new Date(dateInput);
|
|
if (isNaN(parsed.getTime())) {
|
|
throw new Error(`Invalid date format: ${dateInput}`);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* Returns the UTC instant for a date-only local string (YYYY-MM-DD).
|
|
* Treats the date as midnight (or end-of-day) in the configured timezone.
|
|
* @param {string} dateInput - Date-only string
|
|
* @param {boolean} endOfDayFlag - If true, return end of that local day
|
|
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
|
|
* @returns {Date}
|
|
*/
|
|
function localDateStringToInstant(dateInput, endOfDayFlag = false, timeZone = config.DEFAULT_TIMEZONE) {
|
|
const tz = getEffectiveTimeZone(timeZone);
|
|
const [year, month, day] = dateInput.split('-').map(Number);
|
|
if (endOfDayFlag) {
|
|
return localToUtc(year, month, day, 23, 59, 59, 999, tz);
|
|
}
|
|
return localToUtc(year, month, day, 0, 0, 0, 0, tz);
|
|
}
|
|
|
|
/**
|
|
* Processes predefined date ranges
|
|
* @param {string} dateRange - Predefined range
|
|
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
|
|
* @returns {Object} - Object with from and to dates
|
|
*/
|
|
function processDateRange(dateRange, timeZone = config.DEFAULT_TIMEZONE) {
|
|
if (!dateRange) return null;
|
|
|
|
const tz = getEffectiveTimeZone(timeZone);
|
|
const now = new Date();
|
|
const today = new Date(now);
|
|
|
|
switch (dateRange.toLowerCase()) {
|
|
case 'today':
|
|
return {
|
|
from: startOfDay(today, tz),
|
|
to: endOfDay(today, tz)
|
|
};
|
|
|
|
case 'yesterday': {
|
|
const { year, month, day } = getLocalDateComponents(today, tz);
|
|
const yesterday = subtractLocalDays(year, month, day, 1);
|
|
return {
|
|
from: localToUtc(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0, 0, tz),
|
|
to: localToUtc(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59, 999, tz)
|
|
};
|
|
}
|
|
|
|
case 'last7days':
|
|
return {
|
|
from: startOfDay(new Date(now.getTime() - 7*24*60*60*1000), tz),
|
|
to: endOfDay(now, tz)
|
|
};
|
|
|
|
case 'last30days':
|
|
return {
|
|
from: startOfDay(new Date(now.getTime() - 30*24*60*60*1000), tz),
|
|
to: endOfDay(now, tz)
|
|
};
|
|
|
|
case 'last90days':
|
|
return {
|
|
from: startOfDay(new Date(now.getTime() - 90*24*60*60*1000), tz),
|
|
to: endOfDay(now, tz)
|
|
};
|
|
|
|
case 'thisweek': {
|
|
const { year, month, day } = getLocalDateComponents(today, tz);
|
|
const localMidnight = localToUtc(year, month, day, 0, 0, 0, 0, tz);
|
|
const dayOfWeek = localMidnight.getUTCDay();
|
|
const startOfWeek = subtractLocalDays(year, month, day, dayOfWeek);
|
|
return {
|
|
from: localToUtc(startOfWeek.year, startOfWeek.month, startOfWeek.day, 0, 0, 0, 0, tz),
|
|
to: endOfDay(today, tz)
|
|
};
|
|
}
|
|
|
|
case 'lastweek': {
|
|
const { year, month, day } = getLocalDateComponents(today, tz);
|
|
const localMidnight = localToUtc(year, month, day, 0, 0, 0, 0, tz);
|
|
const dayOfWeek = localMidnight.getUTCDay();
|
|
const startOfLastWeek = subtractLocalDays(year, month, day, dayOfWeek + 7);
|
|
const endOfLastWeek = subtractLocalDays(year, month, day, dayOfWeek + 1);
|
|
return {
|
|
from: localToUtc(startOfLastWeek.year, startOfLastWeek.month, startOfLastWeek.day, 0, 0, 0, 0, tz),
|
|
to: localToUtc(endOfLastWeek.year, endOfLastWeek.month, endOfLastWeek.day, 23, 59, 59, 999, tz)
|
|
};
|
|
}
|
|
|
|
case 'thismonth': {
|
|
const { year, month } = getLocalDateComponents(today, tz);
|
|
return {
|
|
from: localToUtc(year, month, 1, 0, 0, 0, 0, tz),
|
|
to: endOfDay(today, tz)
|
|
};
|
|
}
|
|
|
|
case 'lastmonth': {
|
|
const { year, month } = getLocalDateComponents(today, tz);
|
|
const startOfLastMonth = subtractLocalMonths(year, month, 1);
|
|
// Last day of the previous month (in UTC calendar arithmetic, which mirrors local Y/M).
|
|
const lastDay = new Date(Date.UTC(startOfLastMonth.year, startOfLastMonth.month, 0)).getUTCDate();
|
|
return {
|
|
from: localToUtc(startOfLastMonth.year, startOfLastMonth.month, 1, 0, 0, 0, 0, tz),
|
|
to: localToUtc(startOfLastMonth.year, startOfLastMonth.month, lastDay, 23, 59, 59, 999, tz)
|
|
};
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown date range: ${dateRange}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subtract a number of days from a local calendar date.
|
|
* @param {number} year
|
|
* @param {number} month
|
|
* @param {number} day
|
|
* @param {number} days
|
|
* @returns {{year:number, month:number, day:number}}
|
|
*/
|
|
function subtractLocalDays(year, month, day, days) {
|
|
const d = new Date(Date.UTC(year, month - 1, day));
|
|
d.setUTCDate(d.getUTCDate() - days);
|
|
return {
|
|
year: d.getUTCFullYear(),
|
|
month: d.getUTCMonth() + 1,
|
|
day: d.getUTCDate()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Subtract a number of months from a local calendar date.
|
|
* @param {number} year
|
|
* @param {number} month
|
|
* @param {number} months
|
|
* @returns {{year:number, month:number}}
|
|
*/
|
|
function subtractLocalMonths(year, month, months) {
|
|
let newMonth = month - months;
|
|
let newYear = year;
|
|
while (newMonth < 1) {
|
|
newMonth += 12;
|
|
newYear -= 1;
|
|
}
|
|
while (newMonth > 12) {
|
|
newMonth -= 12;
|
|
newYear += 1;
|
|
}
|
|
return { year: newYear, month: newMonth };
|
|
}
|
|
|
|
/**
|
|
* Builds date filter conditions for OData queries
|
|
* @param {string} dateFrom - Start date
|
|
* @param {string} dateTo - End date
|
|
* @param {string} dateRange - Predefined range
|
|
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
|
|
* @returns {Array<string>} - Array of filter conditions
|
|
*/
|
|
function buildDateFilter(dateFrom, dateTo, dateRange, timeZone = config.DEFAULT_TIMEZONE) {
|
|
const conditions = [];
|
|
|
|
try {
|
|
if (dateRange) {
|
|
const range = processDateRange(dateRange, timeZone);
|
|
if (range) {
|
|
conditions.push(`receivedDateTime ge ${range.from.toISOString()}`);
|
|
conditions.push(`receivedDateTime le ${range.to.toISOString()}`);
|
|
}
|
|
} else {
|
|
if (dateFrom) {
|
|
// Date-only strings are local-day boundaries in the configured timezone
|
|
if (dateFrom.length === 10) {
|
|
conditions.push(`receivedDateTime ge ${localDateStringToInstant(dateFrom, false, timeZone).toISOString()}`);
|
|
} else {
|
|
const fromDate = parseDate(dateFrom);
|
|
conditions.push(`receivedDateTime ge ${fromDate.toISOString()}`);
|
|
}
|
|
}
|
|
if (dateTo) {
|
|
if (dateTo.length === 10) {
|
|
conditions.push(`receivedDateTime le ${localDateStringToInstant(dateTo, true, timeZone).toISOString()}`);
|
|
} else {
|
|
const toDate = parseDate(dateTo);
|
|
conditions.push(`receivedDateTime le ${toDate.toISOString()}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Date filter error: ${error.message}`);
|
|
// Return empty conditions on error to avoid breaking the query
|
|
}
|
|
|
|
return conditions;
|
|
}
|
|
|
|
module.exports = {
|
|
escapeODataString,
|
|
buildODataFilter,
|
|
parseDate,
|
|
processDateRange,
|
|
buildDateFilter,
|
|
startOfDay,
|
|
endOfDay,
|
|
localDateStringToInstant,
|
|
getEffectiveTimeZone
|
|
};
|