/** * 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} 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} - 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 };