// Pure renewal rules. Nothing in this file calls an Apps Script service, // so the same source runs in Apps Script and in the local Node.js tests. const RENEWAL_HEADERS_ = ['Customer', 'Email', 'Renewal date', 'Status', 'Reminder drafted']; const RENEWAL_STATUSES_ = ['Active', 'Renewed', 'Cancelled']; const REMINDER_WINDOW_DAYS_ = 14; const RENEWAL_DATE_COLUMN_ = 3; const REMINDER_DRAFTED_COLUMN_ = 5; /** * @typedef {Object} RenewalRow * @property {string} customer * @property {string} email * @property {Date} renewalDate * @property {string} status * @property {boolean} reminderDrafted */ /** * @typedef {Object} ReminderEmail * @property {number} rowNumber * @property {string} to * @property {string} subject * @property {string} body */ /** * @typedef {Object} RowProblem * @property {number} rowNumber * @property {string} message */ /** * @param {*} value * @returns {boolean} */ function isValidDate_(value) { return Object.prototype.toString.call(value) === '[object Date]' && !isNaN(value.getTime()); } /** * Returns an error message for a header row, or an empty string when it matches. * * @param {Array<*>} header * @returns {string} */ function headerProblem_(header) { const matches = header.length === RENEWAL_HEADERS_.length && header.every(function (value, index) { return value === RENEWAL_HEADERS_[index]; }); return matches ? '' : 'Row 1 must be: ' + RENEWAL_HEADERS_.join(', ') + '.'; } /** * Validates one sheet row. Returns either a parsed row or a problem message. * * @param {Array<*>} values * @param {number} rowNumber * @returns {{row: RenewalRow} | {problem: RowProblem}} */ function validateRenewalRow_(values, rowNumber) { const customer = typeof values[0] === 'string' ? values[0].trim() : ''; const email = typeof values[1] === 'string' ? values[1].trim() : ''; const renewalDate = values[2]; const status = values[3]; const drafted = values[4]; /** @param {string} message */ function fail(message) { return { problem: { rowNumber: rowNumber, message: message } }; } if (customer === '') { return fail('Customer is empty.'); } if (!/^[^\s@]+@[^\s@]+$/.test(email)) { return fail('Email must look like name@example.com.'); } if (!isValidDate_(renewalDate)) { return fail('Renewal date must be a date cell.'); } if (typeof status !== 'string' || RENEWAL_STATUSES_.indexOf(status) === -1) { return fail('Status must be one of: ' + RENEWAL_STATUSES_.join(', ') + '.'); } if (drafted !== '' && !isValidDate_(drafted)) { return fail('Reminder drafted must be empty or a date.'); } return { row: { customer: customer, email: email, renewalDate: renewalDate, status: status, reminderDrafted: drafted !== '', }, }; } /** * Counts calendar days from today to the target date, ignoring the time of day. * Uses the runtime's local calendar date for both values. * * @param {Date} today * @param {Date} target * @returns {number} */ function daysUntil_(today, target) { const start = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()); const end = Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()); return Math.round((end - start) / 86400000); } /** * @param {Date} date * @returns {string} */ function formatIsoDate_(date) { const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return date.getFullYear() + '-' + month + '-' + day; } /** * @param {RenewalRow} row * @param {number} daysLeft * @param {number} rowNumber * @returns {ReminderEmail} */ function buildReminderEmail_(row, daysLeft, rowNumber) { const when = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'tomorrow' : 'in ' + daysLeft + ' days'; return { rowNumber: rowNumber, to: row.email, subject: 'Your subscription renews ' + when, body: [ 'Hello ' + row.customer + ',', '', 'Your subscription renews on ' + formatIsoDate_(row.renewalDate) + ' (' + when + ').', 'Reply to this email if you want to change or cancel it.', ].join('\n'), }; } /** * Decides which data rows need a reminder. Does not read or write anything. * * @param {Array>} dataRows Rows below the header, in sheet order. * @param {Date} today * @returns {{reminders: ReminderEmail[], problems: RowProblem[]}} */ function planReminders_(dataRows, today) { /** @type {ReminderEmail[]} */ const reminders = []; /** @type {RowProblem[]} */ const problems = []; dataRows.forEach(function (values, index) { const rowNumber = index + 2; if (values.every(function (value) { return value === ''; })) { return; } const result = validateRenewalRow_(values, rowNumber); if ('problem' in result) { problems.push(result.problem); return; } const row = result.row; if (row.status !== 'Active' || row.reminderDrafted) { return; } const daysLeft = daysUntil_(today, row.renewalDate); if (daysLeft >= 0 && daysLeft <= REMINDER_WINDOW_DAYS_) { reminders.push(buildReminderEmail_(row, daysLeft, rowNumber)); } }); return { reminders: reminders, problems: problems }; } /** * Returns the data rows whose Reminder drafted cell must be cleared after an * edit, or null when the edit does not touch a renewal date below the header. * * @param {number} row First edited row. * @param {number} column First edited column. * @param {number} numRows * @param {number} numColumns * @returns {{firstRow: number, numRows: number} | null} */ function reminderRowsToReset_(row, column, numRows, numColumns) { const lastColumn = column + numColumns - 1; if (column > RENEWAL_DATE_COLUMN_ || lastColumn < RENEWAL_DATE_COLUMN_) { return null; } const firstRow = Math.max(row, 2); const lastRow = row + numRows - 1; if (lastRow < firstRow) { return null; } return { firstRow: firstRow, numRows: lastRow - firstRow + 1 }; }