diff --git a/.env.example b/.env.example index 7359a3f..c343dfd 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,17 @@ INFO_SESSION_BANNER_URL=./ntbanner.png # Periodic Checks CHECK_LEFT_USERS_ENABLED=false +# Hour approval DMs (Hour Verification tab on EVENTS_SHEET_ID) +HOUR_APPROVAL_ENABLED=false +HOUR_APPROVAL_POLL_MINUTES=5 +HOUR_APPROVAL_LOOKBACK_DAYS=30 +HOUR_APPROVAL_SESSION_HOURS=168 +# Optional Hour Verification layout (defaults shown) +HOUR_VERIFICATION_SHEET_RANGE=A1:ZZ +HOUR_VERIFICATION_HEADER_ROWS=2 +HOUR_VERIFICATION_CONFIRMER_FIELD_COLUMN=5 +HOUR_VERIFICATION_DATE_COLUMN=4 + # Health Check # Interval (in ms) between Healthchecks.io pings. 0 or unset disables pings. Example: 120000 = 2 minutes. HEALTH_CHECK_INTERVAL_MS=0 diff --git a/CLAUDE.md b/CLAUDE.md index 12de186..0ffc7d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,9 @@ Singleton persisted to `data/member-cache.json`. Populated by `events/ready.js` ### Calendar sync (`utils/calendarSync.js`) `startCalendarSync(client, minutes)` polls `CALENDAR_ICAL_URL` and reconciles iCal VEVENTs containing "Info Session" with Discord `GuildScheduledEvent`s in a stage channel. UID→Discord-event-ID mapping persists to `data/event-mapping.json`. +### Hour approval sync (`utils/hourApprovalSync.js`) +When `HOUR_APPROVAL_ENABLED=true`, `startHourApprovalSync(client, minutes)` polls the **Hour Verification** tab on `EVENTS_SHEET_ID` for pending verdicts (`empty`, `Pending`, `Unverified`) within `HOUR_APPROVAL_LOOKBACK_DAYS` (default 30). For each new row, it DMs the department approver from the Leadership sheet (`getContacts`) with Approve/Decline buttons. Row numbers already notified are tracked in `data/hour-approval-notified.json`. In-flight button sessions live on `client.hourApprovalPending` (keyed by sheet row number) with a `timeoutId` — clear via `clearHourApprovalSession` on every terminal branch (same discipline as `verificationPending`). Button handlers are in `utils/hourApprovalSync.js` (`handleHourApprovalButton`, routed from `events/interactionCreate.js`): `hour_approve_*`, `hour_change_*` (modal in `events/modalSubmit.js` via `handleHourApprovalModal`), `hour_decline_*`. Approve/Change/Decline write **Approved**, **Changed**, or **Denied** into the row cell under the assigned confirmer's header column (`setConfirmerHourStatus` in `utils/sheets.js`). Assigned confirmer is read from column F by default; header map is built from rows 1–2. **Change** also updates column B (hours). + ### Health check `index.js` has a Healthchecks.io URL and a `HEALTH_CHECK_INTERVAL` constant. Set to `0` currently, which disables pings — set to a positive millisecond value to re-enable. diff --git a/README.md b/README.md index c27a0bf..25969ae 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,32 @@ View the volunteer hours leaderboard. #### `/requesthours` Get the link to the volunteer hours request form. +### Automated Hour Approval (optional) + +When enabled, the bot polls the **Hour Verification** sheet and DMs department leadership to approve or decline pending requests. + +**Environment variables:** +- `HOUR_APPROVAL_ENABLED=true` — Turn on polling and DMs +- `HOUR_APPROVAL_POLL_MINUTES=5` — How often to scan for new rows (default: 5) +- `HOUR_APPROVAL_LOOKBACK_DAYS=30` — Only notify for requests dated within the last N days +- `HOUR_APPROVAL_SESSION_HOURS=168` — How long Approve/Change/Decline buttons stay active (default: 7 days) + +**Flow:** +1. Volunteer submits hours (Google Form → Hour Verification row with pending verdict) +2. Bot finds leadership contact for that row's confirmer (`LEADERSHIP_SHEET_ID`, Discord User ID required) +3. Confirmer receives a DM with request details and **Approve** / **Change** / **Decline** buttons +4. **Approve** → Sets `Approved` in the column under that confirmer's name (header row) +5. **Change** → Modal for revised hours → Column B updated, confirmer column set to `Changed` +6. **Decline** → Sets `Denied` in the confirmer's column + +The assigned confirmer for each row is read from column F (configurable via `HOUR_VERIFICATION_CONFIRMER_FIELD_COLUMN`). Header rows (default: rows 1–2) must include a matching verdict column for that confirmer (individual name, or group labels like `Anyone on the EC` / `Anyone on the EC/BD`). Group labels DM the first EC leadership contact with a Discord ID on the Leadership sheet. + +**Requirements:** +- Leadership sheet must list Discord User IDs for department contacts +- Approver must allow DMs from server members +- Service account needs **write** access to the Events spreadsheet (Hour Verification tab) +- Notified row numbers persist in `data/hour-approval-notified.json` (not committed to git) + **Cooldown:** 10 seconds **Features:** @@ -407,6 +433,10 @@ INFO_SESSION_BANNER_URL=path_or_url_to_banner # Feature Flags CHECK_LEFT_USERS_ENABLED=false +HOUR_APPROVAL_ENABLED=false +HOUR_APPROVAL_POLL_MINUTES=5 +HOUR_APPROVAL_LOOKBACK_DAYS=30 +HOUR_APPROVAL_SESSION_HOURS=168 ``` 4. **Set up Google Sheets credentials:** @@ -459,6 +489,16 @@ The bot expects the following sheets: - Column A: Name - Column K: Total Hours +- **Tab:** `Hour Verification` + - Column A: Name + - Column B: Hours + - Column D: Department + - Column E: Date (used for lookback filtering) + - Column F: Assigned confirmer name (must match a header column) + - Column H: Type of task + - Column I: Description + - Confirmer columns (headers in rows 1–2): each confirmer has a column; per-row cells use dropdown values `Approved`, `Changed`, or `Denied` + - **Tab:** `Membership Status` - Column A: Name (starts at row 10) - Column B: Status (Member, New Member, Paused, Not a Member, Unknown) @@ -502,6 +542,7 @@ The bot integrates with Google Sheets for: ### Data Writing - **User verification** - Logs new member verification data - **Left users tracking** - Marks users who left the server (red background) +- **Hour verification** - Updates verdict (column C) and approver name (column F) when leadership approves via DM ### Authentication Uses Google Service Account authentication with OAuth 2.0. The service account must have: @@ -605,6 +646,7 @@ Project-NexTech-discord-bot/ ├── utils/ │ ├── sheets.js # Google Sheets API integration │ ├── calendarSync.js # Calendar synchronization +│ ├── hourApprovalSync.js # Hour verification approval DMs │ ├── memberCache.js # Persistent member cache system │ └── helpers.js # Utility functions and embed builders ├── data/ @@ -631,6 +673,7 @@ Project-NexTech-discord-bot/ - **deploy-commands.js** - Registers slash commands with Discord API - **utils/sheets.js** - Handles all Google Sheets operations - **utils/calendarSync.js** - Syncs iCal events to Discord scheduled events +- **utils/hourApprovalSync.js** - Polls Hour Verification sheet and sends approval DMs to leadership - **utils/memberCache.js** - Persistent member data caching system - **utils/helpers.js** - Reusable functions and embed builders - **events/interactionCreate.js** - Handles slash commands, autocomplete, and button interactions (including nickname conflict resolution) diff --git a/events/interactionCreate.js b/events/interactionCreate.js index ebc2285..90ad826 100644 --- a/events/interactionCreate.js +++ b/events/interactionCreate.js @@ -1,8 +1,24 @@ -const { Collection, Events, MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = require('discord.js'); +const { + Collection, + Events, + MessageFlags, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, +} = require('discord.js'); +const { handleHourApprovalButton } = require('../utils/hourApprovalSync'); module.exports = { name: Events.InteractionCreate, async execute(interaction) { + if (interaction.isButton()) { + const handled = await handleHourApprovalButton(interaction); + if (handled) { + return; + } + } + // Handle single-name confirmation button if (interaction.isButton() && interaction.customId.startsWith('single_name_continue_')) { const userId = interaction.customId.replace('single_name_continue_', ''); diff --git a/events/modalSubmit.js b/events/modalSubmit.js index 52b857d..06ffe35 100644 --- a/events/modalSubmit.js +++ b/events/modalSubmit.js @@ -1,11 +1,17 @@ const { Events, EmbedBuilder, MessageFlags } = require('discord.js'); const sheetsManager = require('../utils/sheets'); +const { handleHourApprovalModal } = require('../utils/hourApprovalSync'); module.exports = { name: Events.InteractionCreate, async execute(interaction) { if (!interaction.isModalSubmit()) return; + const hourApprovalHandled = await handleHourApprovalModal(interaction); + if (hourApprovalHandled) { + return; + } + // Handle nickname conflict resolution modal if (interaction.customId.startsWith('nickname_conflict_')) { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); diff --git a/events/ready.js b/events/ready.js index 754bde3..149982d 100644 --- a/events/ready.js +++ b/events/ready.js @@ -1,6 +1,7 @@ const { Events } = require('discord.js'); const sheetsManager = require('../utils/sheets'); const { startCalendarSync } = require('../utils/calendarSync'); +const { startHourApprovalSync } = require('../utils/hourApprovalSync'); const memberCache = require('../utils/memberCache'); module.exports = { @@ -43,7 +44,7 @@ module.exports = { if (memberCache.isInitialLoad) { memberCache.isInitialLoad = false; } - } + } catch (error) { console.error('⚠️ Failed to fetch guild members:', error.message); } @@ -76,6 +77,22 @@ module.exports = { console.error('❌ Failed to start calendar sync:', error.message); } + // Start automatic hour approval notifications (if enabled) + const hourApprovalEnabled = process.env.HOUR_APPROVAL_ENABLED === 'true'; + if (hourApprovalEnabled) { + try { + const pollMinutes = parseInt(process.env.HOUR_APPROVAL_POLL_MINUTES, 10) || 5; + startHourApprovalSync(client, pollMinutes); + console.log('✅ Hour approval sync started'); + } + catch (error) { + console.error('❌ Failed to start hour approval sync:', error.message); + } + } + else { + console.log('ℹ️ Hour approval sync is disabled (HOUR_APPROVAL_ENABLED=false)'); + } + // Start periodic check for users who left the server (if enabled) const checkLeftUsersEnabled = process.env.CHECK_LEFT_USERS_ENABLED === 'true'; if (checkLeftUsersEnabled) { @@ -97,7 +114,7 @@ module.exports = { catch (error) { console.error('❌ Failed to start left users checker:', error.message); } - } + } else { console.log('ℹ️ Left users checker is disabled (CHECK_LEFT_USERS_ENABLED=false)'); } diff --git a/index.js b/index.js index 065d724..7e2a442 100644 --- a/index.js +++ b/index.js @@ -81,6 +81,7 @@ rl.on('line', (input) => { }); client.commands = new Collection(); +client.hourApprovalPending = new Map(); const foldersPath = path.join(__dirname, 'commands'); const commandFolders = fs.readdirSync(foldersPath); client.cooldowns = new Collection(); diff --git a/package.json b/package.json index 2fee416..7b1a8fb 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node index.js", + "start-tester": "ENV_FILE=.env.tester node index.js", "deploy-prod": "node --env-file=prod.env deploy-commands.js", "start-prod": "node --env-file=prod.env index.js" }, diff --git a/scripts/debug-pending.js b/scripts/debug-pending.js new file mode 100644 index 0000000..7d98f01 --- /dev/null +++ b/scripts/debug-pending.js @@ -0,0 +1,42 @@ +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.tester'), override: true }); +const sheetsManager = require('../utils/sheets'); + +function loadNotifiedRows() { + const notifiedFilePath = path.join(__dirname, '..', 'data', 'hour-approval-notified.json'); + try { + if (fs.existsSync(notifiedFilePath)) { + const data = JSON.parse(fs.readFileSync(notifiedFilePath, 'utf8')); + return new Set(data.notifiedRowNumbers || []); + } + } + catch (e) { + console.error('Failed to load notified file', e); + } + return new Set(); +} + +(async () => { + try { + await sheetsManager.initialize(); + const result = await sheetsManager.getNewHourVerificationRequests(parseInt(process.env.HOUR_APPROVAL_LOOKBACK_DAYS, 10) || 30); + if (!result) { + console.error('No result from getNewHourVerificationRequests'); + process.exit(2); + } + + const notified = loadNotifiedRows(); + console.log('Notified rows count =', notified.size); + console.log('Pending requests count =', result.requests.length); + for (const req of result.requests) { + const approver = await sheetsManager.getApproverForConfirmer(req.confirmer); + console.log(`Row ${req.rowNumber}: confirmer='${req.confirmer}', confirmerColumnIndex=${req.confirmerColumnIndex}, verdict='${req.verdict}', inNotified=${notified.has(req.rowNumber)}, approver=${approver ? approver.name + '|' + approver.discordId : 'NULL'}`); + } + process.exit(0); + } + catch (error) { + console.error('Error:', error); + process.exit(1); + } +})(); diff --git a/scripts/print-row-3109.js b/scripts/print-row-3109.js new file mode 100644 index 0000000..03644b5 --- /dev/null +++ b/scripts/print-row-3109.js @@ -0,0 +1,57 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.tester') }); +const sheetsManager = require('../utils/sheets'); + +(async () => { + try { + await sheetsManager.initialize(); + + const grid = await sheetsManager.fetchHourVerificationGrid(); + if (!grid) { + console.error('Failed to fetch Hour Verification grid'); + process.exit(2); + } + + const { rows, headerRowCount, dateColumnIndex, confirmerColumnMap } = grid; + console.log('headerRowCount=', headerRowCount, 'dateColumnIndex=', dateColumnIndex); + + console.log('confirmerColumnMap keys (first 50):'); + let i = 0; + for (const [k, v] of confirmerColumnMap.entries()) { + console.log(` [${v}] ${k}`); + i++; + if (i >= 50) break; + } + + const targetRowNumber = 3109; + const idx = targetRowNumber - 1; + console.log(`rows.length = ${rows.length}`); + if (idx < 0 || idx >= rows.length) { + console.error(`Row ${targetRowNumber} is out of range`); + process.exit(3); + } + + const row = rows[idx]; + console.log(`\nRaw row ${targetRowNumber}:`, JSON.stringify(row)); + + const confirmerFieldColumn = sheetsManager.getHourVerificationConfirmerFieldColumn(); + const confirmerValue = (row[confirmerFieldColumn] || '').toString().trim(); + const confirmerColumnIndex = sheetsManager.resolveConfirmerColumnIndex(confirmerValue, confirmerColumnMap); + console.log('confirmerFieldColumn(index) =', confirmerFieldColumn, 'confirmerValue =', confirmerValue); + console.log('confirmerColumnIndex =', confirmerColumnIndex); + + const overallVerdict = row[2] || ''; + console.log('overallVerdict (col C) =', overallVerdict); + if (confirmerColumnIndex !== null) { + console.log('confirmerColumnValue =', row[confirmerColumnIndex] || ''); + } else { + console.log('No specific confirmer column found; fallback verdict used.'); + } + + process.exit(0); + } + catch (error) { + console.error('Script error:', error); + process.exit(1); + } +})(); diff --git a/utils/hourApprovalSync.js b/utils/hourApprovalSync.js new file mode 100644 index 0000000..bfba5bb --- /dev/null +++ b/utils/hourApprovalSync.js @@ -0,0 +1,532 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} = require('discord.js'); +const sheetsManager = require('./sheets'); + +const notifiedFilePath = path.join(__dirname, '..', 'data', 'hour-approval-notified.json'); +const DEFAULT_LOOKBACK_DAYS = 30; +const DEFAULT_SESSION_HOURS = 168; // 7 days + +/** + * @returns {Set} + */ +function loadNotifiedRows() { + try { + if (fs.existsSync(notifiedFilePath)) { + const data = JSON.parse(fs.readFileSync(notifiedFilePath, 'utf8')); + return new Set(data.notifiedRowNumbers || []); + } + } + catch (error) { + console.error('[HourApproval] Error loading notified rows:', error); + } + return new Set(); +} + +/** + * @param {Set} notifiedRows + */ +function saveNotifiedRows(notifiedRows) { + try { + fs.writeFileSync( + notifiedFilePath, + JSON.stringify({ notifiedRowNumbers: [...notifiedRows] }, null, 2), + 'utf8', + ); + } + catch (error) { + console.error('[HourApproval] Error saving notified rows:', error); + } +} + +/** + * @param {Object} request + * @returns {EmbedBuilder} + */ +function buildHourApprovalEmbed(request) { + return new EmbedBuilder() + .setColor(0xFAA61A) + .setTitle('⏳ Hour Request Needs Approval') + .setDescription(`A volunteer submitted hours that need your review.`) + .addFields( + { name: 'Volunteer', value: request.name, inline: true }, + { name: 'Hours', value: String(request.hours), inline: true }, + { name: 'Confirmer', value: request.confirmer, inline: true }, + { name: 'Department', value: request.department, inline: true }, + { name: 'Date', value: String(request.date), inline: true }, + { name: 'Type', value: String(request.type), inline: true }, + { name: 'Sheet Row', value: String(request.rowNumber), inline: true }, + { name: 'Description', value: request.description || 'No description provided' }, + ) + .setFooter({ text: 'Project NexTech Hour Verification' }) + .setTimestamp(); +} + +/** + * @param {number} rowNumber + * @returns {ActionRowBuilder} + */ +function buildApprovalButtons(rowNumber) { + return new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`hour_approve_${rowNumber}`) + .setLabel('Approve') + .setStyle(ButtonStyle.Success) + .setEmoji('✅'), + new ButtonBuilder() + .setCustomId(`hour_change_${rowNumber}`) + .setLabel('Change') + .setStyle(ButtonStyle.Primary) + .setEmoji('✏️'), + new ButtonBuilder() + .setCustomId(`hour_decline_${rowNumber}`) + .setLabel('Decline') + .setStyle(ButtonStyle.Danger) + .setEmoji('❌'), + ); +} + +/** + * @param {string} value + * @returns {number|null} + */ +function parseHoursInput(value) { + const trimmed = (value || '').trim(); + if (!trimmed) { + return null; + } + + const hours = parseFloat(trimmed); + if (Number.isNaN(hours) || hours <= 0) { + return null; + } + + return hours; +} + +/** + * @param {import('discord.js').ButtonInteraction|import('discord.js').ModalSubmitInteraction} interaction + * @param {number} rowNumber + * @returns {Promise} Pending session, or null if already replied + */ +async function getHourApprovalSession(interaction, rowNumber) { + if (!interaction.client.hourApprovalPending?.has(rowNumber)) { + await interaction.reply({ + content: '❌ This approval request has expired or was already handled.', + ephemeral: true, + }); + return null; + } + + const pending = interaction.client.hourApprovalPending.get(rowNumber); + + if (interaction.user.id !== pending.approverId) { + await interaction.reply({ + content: '❌ Only the assigned confirmer can use these controls.', + ephemeral: true, + }); + return null; + } + + return pending; +} + +/** + * @param {number} rowNumber + * @param {number} confirmerColumnIndex + * @returns {Promise} + */ +async function isHourRequestStillPending(rowNumber, confirmerColumnIndex) { + return sheetsManager.isConfirmerRowPending(rowNumber, confirmerColumnIndex); +} + +/** + * @param {import('discord.js').Client} client + * @param {Object} request + * @param {Object} approverContact + * @param {Set} notifiedRows + */ +async function notifyApprover(client, request, approverContact, notifiedRows) { + try { + const approverUser = await client.users.fetch(approverContact.discordId); + const embed = buildHourApprovalEmbed(request); + const components = [buildApprovalButtons(request.rowNumber)]; + + const dmMessage = await approverUser.send({ embeds: [embed], components }); + + if (!client.hourApprovalPending) { + client.hourApprovalPending = new Map(); + } + + const sessionHours = parseInt(process.env.HOUR_APPROVAL_SESSION_HOURS, 10) || DEFAULT_SESSION_HOURS; + const timeoutMs = sessionHours * 60 * 60 * 1000; + + const timeoutId = setTimeout(() => { + expireHourApprovalSession(client, request.rowNumber, dmMessage); + }, timeoutMs); + + client.hourApprovalPending.set(request.rowNumber, { + request, + approverId: approverContact.discordId, + approverSheetName: approverContact.name, + messageId: dmMessage.id, + channelId: dmMessage.channel.id, + timeoutId, + }); + + notifiedRows.add(request.rowNumber); + saveNotifiedRows(notifiedRows); + + console.log( + `[HourApproval] Sent approval DM for row ${request.rowNumber} ` + + `(${request.name}) to ${approverContact.name}`, + ); + } + catch (error) { + console.error( + `[HourApproval] Failed to DM approver for row ${request.rowNumber}:`, + error.message, + ); + } +} + +/** + * @param {import('discord.js').Client} client + * @param {number} rowNumber + * @param {import('discord.js').Message} dmMessage + */ +async function expireHourApprovalSession(client, rowNumber, dmMessage) { + const pending = client.hourApprovalPending?.get(rowNumber); + if (!pending) { + return; + } + + client.hourApprovalPending.delete(rowNumber); + + try { + const embed = EmbedBuilder.from(dmMessage.embeds[0]) + .setColor(0x95A5A6) + .setTitle('⌛ Hour Approval Expired') + .setDescription('This request was not actioned in time. You can still update the sheet manually.'); + + await dmMessage.edit({ embeds: [embed], components: [] }); + } + catch (error) { + console.error(`[HourApproval] Failed to expire session for row ${rowNumber}:`, error.message); + } +} + +/** + * @param {import('discord.js').Client} client + */ +async function syncHourApprovalRequests(client) { + try { + const lookbackDays = parseInt(process.env.HOUR_APPROVAL_LOOKBACK_DAYS, 10) || DEFAULT_LOOKBACK_DAYS; + const result = await sheetsManager.getNewHourVerificationRequests(lookbackDays); + + if (!result) { + console.error('[HourApproval] Skipping sync — could not read Hour Verification sheet'); + return; + } + + const notifiedRows = loadNotifiedRows(); + let notifiedCount = 0; + + for (const request of result.requests) { + if (notifiedRows.has(request.rowNumber)) { + continue; + } + + if (client.hourApprovalPending?.has(request.rowNumber)) { + continue; + } + + const confirmer = request.confirmer && request.confirmer !== 'N/A' + ? request.confirmer.trim() + : null; + + if (!confirmer) { + console.warn( + `[HourApproval] Row ${request.rowNumber} has no confirmer — cannot find approver`, + ); + continue; + } + + const approver = await sheetsManager.getApproverForConfirmer(confirmer); + + if (!approver) { + console.warn( + `[HourApproval] No leadership contact with Discord ID for confirmer "${confirmer}" ` + + `(row ${request.rowNumber}). For group labels like "Anyone on the EC", add EC members with Discord IDs on the Leadership sheet.`, + ); + continue; + } + + await notifyApprover(client, request, approver, notifiedRows); + notifiedCount++; + } + + if (notifiedCount > 0) { + console.log(`[HourApproval] Sync completed — sent ${notifiedCount} new notification(s)`); + } + } + catch (error) { + console.error('[HourApproval] Error during sync:', error); + } +} + +/** + * @param {import('discord.js').Client} client + * @param {number} intervalMinutes + */ +function startHourApprovalSync(client, intervalMinutes) { + console.log(`[HourApproval] Starting automatic hour approval sync (every ${intervalMinutes} minutes)`); + + syncHourApprovalRequests(client); + + setInterval(() => { + syncHourApprovalRequests(client); + }, intervalMinutes * 60 * 1000); +} + +/** + * Clear a pending hour approval session + * @param {import('discord.js').Client} client + * @param {number} rowNumber + */ +function clearHourApprovalSession(client, rowNumber) { + const pending = client.hourApprovalPending?.get(rowNumber); + if (!pending) { + return; + } + + if (pending.timeoutId) { + clearTimeout(pending.timeoutId); + } + + client.hourApprovalPending.delete(rowNumber); +} + +/** + * Handle Approve / Decline button clicks on hour approval DMs + * @param {import('discord.js').ButtonInteraction} interaction + * @returns {Promise} True if this handler consumed the interaction + */ +async function handleHourApprovalButton(interaction) { + if (!interaction.isButton()) { + return false; + } + + const approveMatch = interaction.customId.match(/^hour_approve_(\d+)$/); + const declineMatch = interaction.customId.match(/^hour_decline_(\d+)$/); + const changeMatch = interaction.customId.match(/^hour_change_(\d+)$/); + const match = approveMatch || declineMatch || changeMatch; + + if (!match) { + return false; + } + + const rowNumber = parseInt(match[1], 10); + const pending = await getHourApprovalSession(interaction, rowNumber); + if (!pending) { + return true; + } + + if (changeMatch) { + const currentHours = pending.request.hours === 'N/A' ? '' : String(pending.request.hours); + const modal = new ModalBuilder() + .setCustomId(`hour_change_${rowNumber}`) + .setTitle('Change Requested Hours'); + + const hoursInput = new TextInputBuilder() + .setCustomId('new_hours') + .setLabel('Hours to approve') + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g. 2 or 1.5') + .setValue(currentHours) + .setRequired(true) + .setMaxLength(10); + + modal.addComponents(new ActionRowBuilder().addComponents(hoursInput)); + await interaction.showModal(modal); + return true; + } + + await interaction.deferUpdate(); + + const confirmerColumnIndex = pending.request.confirmerColumnIndex; + if (!await isHourRequestStillPending(rowNumber, confirmerColumnIndex)) { + clearHourApprovalSession(interaction.client, rowNumber); + await interaction.editReply({ + content: '❌ This request is no longer pending in the sheet.', + embeds: interaction.message.embeds, + components: [], + }); + return true; + } + + const isApprove = Boolean(approveMatch); + const approverName = pending.approverSheetName + || interaction.member?.displayName + || interaction.user.displayName + || interaction.user.username; + const sheetStatus = isApprove ? 'Approved' : 'Denied'; + const success = await sheetsManager.setConfirmerHourStatus( + rowNumber, + confirmerColumnIndex, + sheetStatus, + null, + approverName, + ); + + clearHourApprovalSession(interaction.client, rowNumber); + + if (!success) { + await interaction.editReply({ + content: '❌ Failed to update Google Sheets. Please update the row manually.', + embeds: interaction.message.embeds, + components: [], + }); + return true; + } + + const embed = EmbedBuilder.from(interaction.message.embeds[0]) + .setColor(isApprove ? 0x57F287 : 0xED4245) + .setTitle(isApprove ? '✅ Hour Request Approved' : '❌ Hour Request Declined') + .setDescription( + isApprove + ? `Set **Approved** in **${pending.request.confirmer}**'s column (by **${approverName}**).` + : `Set **Denied** in **${pending.request.confirmer}**'s column.`, + ); + + await interaction.editReply({ + content: null, + embeds: [embed], + components: [], + }); + + console.log( + `[HourApproval] Row ${rowNumber} ${isApprove ? 'approved' : 'denied'} by ${interaction.user.tag}`, + ); + + return true; +} + +/** + * Handle modal submission to change hours and approve + * @param {import('discord.js').ModalSubmitInteraction} interaction + * @returns {Promise} + */ +async function handleHourApprovalModal(interaction) { + if (!interaction.isModalSubmit()) { + return false; + } + + const match = interaction.customId.match(/^hour_change_(\d+)$/); + if (!match) { + return false; + } + + const rowNumber = parseInt(match[1], 10); + const pending = await getHourApprovalSession(interaction, rowNumber); + if (!pending) { + return true; + } + + const newHours = parseHoursInput(interaction.fields.getTextInputValue('new_hours')); + if (newHours === null) { + await interaction.reply({ + content: '❌ Enter a valid number of hours greater than zero (e.g. `2` or `1.5`).', + ephemeral: true, + }); + return true; + } + + await interaction.deferReply({ ephemeral: true }); + + const confirmerColumnIndex = pending.request.confirmerColumnIndex; + if (!await isHourRequestStillPending(rowNumber, confirmerColumnIndex)) { + clearHourApprovalSession(interaction.client, rowNumber); + await interaction.editReply({ + content: '❌ This request is no longer pending in the sheet.', + }); + return true; + } + + const approverName = pending.approverSheetName + || interaction.member?.displayName + || interaction.user.displayName + || interaction.user.username; + const success = await sheetsManager.setConfirmerHourStatus( + rowNumber, + confirmerColumnIndex, + 'Changed', + newHours, + approverName, + ); + + if (!success) { + await interaction.editReply({ + content: '❌ Failed to update Google Sheets. Please update the row manually.', + }); + return true; + } + + clearHourApprovalSession(interaction.client, rowNumber); + + const formattedHours = Number.isInteger(newHours) ? String(newHours) : String(newHours); + + try { + const channel = await interaction.client.channels.fetch(pending.channelId); + const dmMessage = await channel.messages.fetch(pending.messageId); + const originalEmbed = dmMessage.embeds[0]; + const updatedFields = originalEmbed.fields.map(field => + field.name === 'Hours' + ? { name: field.name, value: formattedHours, inline: field.inline } + : field, + ); + + const sourceEmbed = EmbedBuilder.from(originalEmbed) + .setColor(0x57F287) + .setTitle('✅ Hours Changed') + .setDescription( + `Set **Changed** with **${formattedHours}** hour(s) in **${pending.request.confirmer}**'s column (by **${approverName}**).`, + ) + .setFields(updatedFields); + + await dmMessage.edit({ embeds: [sourceEmbed], components: [] }); + } + catch (error) { + console.error(`[HourApproval] Failed to edit DM after change for row ${rowNumber}:`, error.message); + } + + await interaction.editReply({ + content: `✅ Set **Changed** with **${formattedHours}** hour(s) in the sheet.`, + }); + + console.log( + `[HourApproval] Row ${rowNumber} approved with ${formattedHours} hour(s) by ${interaction.user.tag}`, + ); + + return true; +} + +module.exports = { + startHourApprovalSync, + syncHourApprovalRequests, + handleHourApprovalButton, + handleHourApprovalModal, + buildHourApprovalEmbed, + clearHourApprovalSession, + loadNotifiedRows, + saveNotifiedRows, +}; diff --git a/utils/sheets.js b/utils/sheets.js index 657d32d..c6337d8 100644 --- a/utils/sheets.js +++ b/utils/sheets.js @@ -229,6 +229,541 @@ class SheetsManager { }; } + /** + * Normalize a confirmer/header name for column lookup + * @param {string} name + * @returns {string} + */ + normalizeConfirmerName(name) { + return (name || '').trim().toLowerCase().replace(/\s+/g, ' '); + } + + /** + * 0-based column index to A1 column letters (0 -> A) + * @param {number} columnIndex + * @returns {string} + */ + columnIndexToLetter(columnIndex) { + let index = columnIndex + 1; + let letters = ''; + + while (index > 0) { + const remainder = (index - 1) % 26; + letters = String.fromCharCode(65 + remainder) + letters; + index = Math.floor((index - 1) / 26); + } + + return letters; + } + + /** + * Read Hour Verification tab including confirmer header columns + * @returns {Promise} + */ + async fetchHourVerificationGrid() { + const eventsSheetId = process.env.EVENTS_SHEET_ID; + const sheetRange = process.env.HOUR_VERIFICATION_SHEET_RANGE || 'A1:ZZ'; + const response = await this.safeApiCall( + () => this.sheets.spreadsheets.values.get({ + spreadsheetId: eventsSheetId, + range: `'Hour Verification'!${sheetRange}`, + }), + 'fetchHourVerificationGrid', + ); + + if (!response || !response.data) { + return null; + } + + const rows = response.data.values || []; + const headerRowCount = this.getHourVerificationHeaderRowCount(rows); + + return { + rows, + headerRowCount, + dateColumnIndex: this.resolveDateColumnIndex(rows, headerRowCount), + confirmerColumnMap: this.buildConfirmerColumnMap(rows, headerRowCount), + }; + } + + /** + * @param {Array>} rows + * @returns {number} + */ + getHourVerificationHeaderRowCount(rows) { + const fromEnv = parseInt(process.env.HOUR_VERIFICATION_HEADER_ROWS, 10); + if (!Number.isNaN(fromEnv) && fromEnv > 0) { + return fromEnv; + } + return 2; + } + + /** + * Normalize lookback days from env or caller + * @param {number|string} lookbackDays + * @returns {number} + */ + normalizeLookbackDays(lookbackDays) { + const parsed = typeof lookbackDays === 'number' + ? lookbackDays + : parseInt(lookbackDays, 10); + + if (!Number.isFinite(parsed) || parsed <= 0) { + return 30; + } + + return Math.floor(parsed); + } + + /** + * Start of local calendar day for date comparisons + * @param {Date} date + * @returns {Date} + */ + toStartOfDay(date) { + const day = new Date(date.getTime()); + day.setHours(0, 0, 0, 0); + return day; + } + + /** + * Whether a request date falls within the lookback window (inclusive) + * @param {Date} requestDate + * @param {number} lookbackDays + * @returns {boolean} + */ + isDateWithinLookback(requestDate, lookbackDays) { + const effectiveLookback = this.normalizeLookbackDays(lookbackDays); + const cutoff = this.toStartOfDay(new Date()); + cutoff.setDate(cutoff.getDate() - effectiveLookback); + + const requestDay = this.toStartOfDay(requestDate); + return requestDay >= cutoff; + } + + /** + * Find the column that holds each row's event/request date + * @param {Array>} rows + * @param {number} headerRowCount + * @returns {number} 0-based column index + */ + resolveDateColumnIndex(rows, headerRowCount) { + const fromEnv = parseInt(process.env.HOUR_VERIFICATION_DATE_COLUMN, 10); + if (!Number.isNaN(fromEnv) && fromEnv >= 0) { + return fromEnv; + } + + const headerRows = rows.slice(0, headerRowCount); + const maxColumns = headerRows.reduce((max, row) => Math.max(max, row.length), 0); + + for (let col = 0; col < maxColumns; col++) { + let label = ''; + for (const headerRow of headerRows) { + const cell = headerRow[col] ? String(headerRow[col]).trim() : ''; + if (cell) { + label = cell; + } + } + + const key = this.normalizeConfirmerName(label); + if (key === 'date' || key === 'event date' || key === 'submission date') { + return col; + } + } + + return 4; // Default column E + } + + /** + * Map confirmer names (from header rows) to 0-based column indices + * @param {Array>} rows + * @param {number} headerRowCount + * @returns {Map} + */ + buildConfirmerColumnMap(rows, headerRowCount) { + const map = new Map(); + const headerRows = rows.slice(0, headerRowCount); + if (headerRows.length === 0) { + return map; + } + + const maxColumns = headerRows.reduce((max, row) => Math.max(max, row.length), 0); + + for (let col = 0; col < maxColumns; col++) { + let label = ''; + for (const headerRow of headerRows) { + const cell = headerRow[col] ? String(headerRow[col]).trim() : ''; + if (cell) { + label = cell; + } + } + + if (!label) { + continue; + } + + const key = this.normalizeConfirmerName(label); + if (!key || map.has(key) || this.isReservedHourVerificationHeader(key)) { + continue; + } + + map.set(key, col); + } + + return map; + } + + /** + * Header labels that are not confirmer verdict columns + * @param {string} normalizedHeader + * @returns {boolean} + */ + isReservedHourVerificationHeader(normalizedHeader) { + const reserved = new Set([ + 'date', + 'event date', + 'submission date', + 'name', + 'hours', + 'hour', + 'department', + 'dept', + 'type', + 'description', + 'desc', + 'confirmer', + 'verdict', + 'status', + 'notes', + 'note', + ]); + + return reserved.has(normalizedHeader); + } + + /** + * Alternate header keys for group-style confirmer assignments + * @param {string} normalizedConfirmer + * @returns {string[]} + */ + getConfirmerColumnSearchKeys(normalizedConfirmer) { + const keys = [normalizedConfirmer]; + + if (normalizedConfirmer.includes('ec/bd') || normalizedConfirmer.includes('ec / bd')) { + keys.push( + 'anyone on the ec/bd', + 'anyone on ec/bd', + 'anyone on the ec / bd', + 'ec/bd', + 'ec / bd', + ); + } + else if (normalizedConfirmer.includes('anyone') && normalizedConfirmer.includes('ec')) { + keys.push( + 'anyone on the ec', + 'anyone on ec', + 'ec', + ); + } + + return [...new Set(keys)]; + } + + /** + * Fuzzy match group confirmer labels to sheet header columns + * @param {string} normalizedConfirmer + * @param {Map} confirmerColumnMap + * @returns {number|null} + */ + findConfirmerColumnByFuzzyHeader(normalizedConfirmer, confirmerColumnMap) { + if (normalizedConfirmer.includes('ec/bd') || normalizedConfirmer.includes('ec / bd')) { + for (const [headerName, columnIndex] of confirmerColumnMap.entries()) { + if (headerName.includes('ec') && headerName.includes('bd')) { + return columnIndex; + } + } + } + + if (normalizedConfirmer.includes('anyone') && normalizedConfirmer.includes('ec')) { + for (const [headerName, columnIndex] of confirmerColumnMap.entries()) { + if (headerName.includes('bd')) { + continue; + } + if (headerName.includes('anyone') && headerName.includes('ec')) { + return columnIndex; + } + if (headerName === 'ec' || headerName === 'anyone on the ec' || headerName === 'anyone on ec') { + return columnIndex; + } + } + } + + return null; + } + + /** + * Find the sheet column for a confirmer's Approved/Changed/Denied cell + * @param {string} confirmerName + * @param {Map} confirmerColumnMap + * @returns {number|null} 0-based column index + */ + resolveConfirmerColumnIndex(confirmerName, confirmerColumnMap) { + const normalized = this.normalizeConfirmerName(confirmerName); + if (!normalized) { + return null; + } + + for (const key of this.getConfirmerColumnSearchKeys(normalized)) { + if (confirmerColumnMap.has(key)) { + return confirmerColumnMap.get(key); + } + } + + for (const [headerName, columnIndex] of confirmerColumnMap.entries()) { + if (normalized.includes(headerName) || headerName.includes(normalized)) { + return columnIndex; + } + } + + return this.findConfirmerColumnByFuzzyHeader(normalized, confirmerColumnMap); + } + + /** + * Column index (0-based) where each row stores the assigned confirmer name + * @returns {number} + */ + getHourVerificationConfirmerFieldColumn() { + const fromEnv = parseInt(process.env.HOUR_VERIFICATION_CONFIRMER_FIELD_COLUMN, 10); + if (!Number.isNaN(fromEnv) && fromEnv >= 0) { + return fromEnv; + } + return 5; // Column F + } + + /** + * Get hour verification requests that are still awaiting approval + * @param {number} lookbackDays - Only include requests with a date within this many days (default 30) + * @returns {Promise} Object with array of pending requests, or null on sheet error + */ + async getNewHourVerificationRequests(lookbackDays = 30) { + const grid = await this.fetchHourVerificationGrid(); + if (!grid) { + console.error('❌ Failed to get hour verification data from Google Sheets'); + return null; + } + + const { rows, headerRowCount, dateColumnIndex, confirmerColumnMap } = grid; + if (rows.length <= headerRowCount) { + return { requests: [] }; + } + + const effectiveLookback = this.normalizeLookbackDays(lookbackDays); + const confirmerFieldColumn = this.getHourVerificationConfirmerFieldColumn(); + + const pendingRequests = []; + for (let i = headerRowCount; i < rows.length; i++) { + const row = rows[i]; + const rowName = row[0] ? row[0].trim() : ''; + if (!rowName) { + continue; + } + + const dateValue = row[dateColumnIndex] ?? ''; + const parsedDate = this.parseHourVerificationDate(dateValue); + if (!parsedDate) { + continue; + } + if (!this.isDateWithinLookback(parsedDate, effectiveLookback)) { + continue; + } + + const confirmer = row[confirmerFieldColumn] ? String(row[confirmerFieldColumn]).trim() : ''; + if (!confirmer) { + continue; + } + + const confirmerColumnIndex = this.resolveConfirmerColumnIndex(confirmer, confirmerColumnMap); + let confirmerStatus; + + if (confirmerColumnIndex !== null) { + confirmerStatus = row[confirmerColumnIndex] ? String(row[confirmerColumnIndex]).trim() : ''; + } + else { + // If no specific column (e.g. group like "Anyone on the EC" or unrecognized), fallback to overall Verdict (Column C, index 2) + confirmerStatus = row[2] ? String(row[2]).trim() : ''; + const sampleHeaders = [...confirmerColumnMap.keys()].slice(0, 12).join(', '); + console.warn( + `[HourVerification] Row ${i + 1}: no header column for "${confirmer}", falling back to overall Verdict.`, + ); + } + + if (!this.isPendingConfirmerStatus(confirmerStatus)) { + continue; + } + + pendingRequests.push({ + rowNumber: i + 1, + name: rowName, + hours: row[1] || 'N/A', + verdict: confirmerStatus || 'Pending', + department: row[3] || 'N/A', + confirmer, + confirmerColumnIndex, + date: dateValue || 'N/A', + type: row[7] || 'N/A', + description: row[8] || 'N/A', + }); + } + + pendingRequests.sort((a, b) => b.rowNumber - a.rowNumber); + + console.log( + `[HourVerification] Found ${pendingRequests.length} pending request(s) ` + + `within ${effectiveLookback} day lookback (date column index ${dateColumnIndex})`, + ); + + return { requests: pendingRequests }; + } + + /** + * Whether a confirmer column cell still needs action + * @param {string} status - Raw cell value + * @returns {boolean} + */ + isPendingConfirmerStatus(status) { + const normalized = (status || '').trim().toLowerCase(); + if (!normalized) { + return true; + } + if (normalized === 'approved' || normalized === 'changed' || normalized === 'denied') { + return false; + } + return normalized === 'pending' || normalized === 'unverified'; + } + + /** + * @param {number} rowNumber + * @param {number} confirmerColumnIndex + * @returns {Promise} + */ + async isConfirmerRowPending(rowNumber, confirmerColumnIndex) { + const grid = await this.fetchHourVerificationGrid(); + if (!grid) { + return false; + } + + const row = grid.rows[rowNumber - 1]; + if (!row) { + return false; + } + + const statusColumn = (confirmerColumnIndex !== null && confirmerColumnIndex !== undefined) ? confirmerColumnIndex : 2; + const status = row[statusColumn] ? String(row[statusColumn]).trim() : ''; + return this.isPendingConfirmerStatus(status); + } + + /** + * Parse a date cell from the Hour Verification sheet + * @param {string|number} dateValue - Raw cell value + * @returns {Date|null} Parsed date, or null if unparseable + */ + parseHourVerificationDate(dateValue) { + if (dateValue === undefined || dateValue === null || dateValue === '') { + return null; + } + + if (typeof dateValue === 'number') { + // Google Sheets serial date (days since 1899-12-30) + const utcMs = (dateValue - 25569) * 86400 * 1000; + const date = new Date(utcMs); + return Number.isNaN(date.getTime()) ? null : date; + } + + const parsed = Date.parse(String(dateValue).trim()); + if (Number.isNaN(parsed)) { + return null; + } + return new Date(parsed); + } + + /** + * Update the hours value (column B) for an hour verification row + * @param {number} rowNumber - 1-indexed sheet row number + * @param {number|string} hours - Hours to write + * @returns {Promise} Success status + */ + async updateHourVerificationHours(rowNumber, hours) { + const eventsSheetId = process.env.EVENTS_SHEET_ID; + + const response = await this.safeApiCall( + () => this.sheets.spreadsheets.values.update({ + spreadsheetId: eventsSheetId, + range: `'Hour Verification'!B${rowNumber}`, + valueInputOption: 'USER_ENTERED', + resource: { + values: [[hours]], + }, + }), + 'updateHourVerificationHours', + ); + + return Boolean(response); + } + + /** + * Set Approved / Changed / Denied in the confirmer's column for a row + * @param {number} rowNumber - 1-indexed sheet row number + * @param {number} confirmerColumnIndex - 0-based column under the confirmer header + * @param {string} status - Approved, Changed, or Denied + * @param {number|string|null} hours - If set, updates column B first + * @returns {Promise} Success status + */ + async setConfirmerHourStatus(rowNumber, confirmerColumnIndex, status, hours = null, approverName = null) { + if (hours !== null && hours !== undefined) { + const hoursUpdated = await this.updateHourVerificationHours(rowNumber, hours); + if (!hoursUpdated) { + return false; + } + } + + let finalColumnIndex = confirmerColumnIndex; + if (finalColumnIndex === null || finalColumnIndex === undefined) { + const grid = await this.fetchHourVerificationGrid(); + if (grid && approverName) { + finalColumnIndex = this.resolveConfirmerColumnIndex(approverName, grid.confirmerColumnMap); + } + if (finalColumnIndex === null || finalColumnIndex === undefined) { + finalColumnIndex = 2; // Fallback to writing in the Verdict column (C) + } + } + + const eventsSheetId = process.env.EVENTS_SHEET_ID; + const columnLetter = this.columnIndexToLetter(finalColumnIndex); + + const response = await this.safeApiCall( + () => this.sheets.spreadsheets.values.update({ + spreadsheetId: eventsSheetId, + range: `'Hour Verification'!${columnLetter}${rowNumber}`, + valueInputOption: 'USER_ENTERED', + resource: { + values: [[status]], + }, + }), + 'setConfirmerHourStatus', + ); + + return Boolean(response); + } + + /** + * Whether an Hour Verification verdict still needs approver action + * @param {string} verdict - Raw verdict cell value + * @returns {boolean} + */ + isPendingHourVerdict(verdict) { + return this.isPendingConfirmerStatus(verdict); + } + /** * Get upcoming events, optionally filtered by department * Sheet is organized by COLUMNS (each column = one event date) @@ -392,6 +927,97 @@ class SheetsManager { return contacts; } + /** + * Get leadership contact by name + * @param {string} name - The name to search for + * @returns {Promise} The contact data or null if not found + */ + async getContactByName(name) { + const spreadsheetId = process.env.LEADERSHIP_SHEET_ID; + + const response = await this.safeApiCall( + () => this.sheets.spreadsheets.values.get({ + spreadsheetId, + range: 'Sheet1!A:F', // Adjust sheet name if needed + }), + 'getContactByName', + ); + + if (!response || !response.data) { + console.error('❌ Failed to get contacts data from Google Sheets for getContactByName'); + return null; + } + + const rows = response.data.values || []; + const normalizedSearchName = name.toLowerCase().trim(); + + // Map data + const contacts = rows.slice(1) + .map(row => ({ + name: row[0] || 'Unknown', + department: row[1] || '', + email: row[2] || 'No email listed', + discordUsername: row[3] || 'Unknown', + discordId: row[4] || '', + role: row[5] || 'Member', + })); + + // Find contact by exact match (case-insensitive) or partial if exact fails + const contact = contacts.find(c => c.name.toLowerCase().trim() === normalizedSearchName); + + if (contact && contact.discordId && contact.discordId.trim()) { + return contact; + } + + // Fallback: partial match + const partialContact = contacts.find(c => normalizedSearchName.includes(c.name.toLowerCase().trim())); + if (partialContact && partialContact.discordId && partialContact.discordId.trim()) { + return partialContact; + } + + return null; + } + + /** + * Pick a leadership contact with Discord ID from a department list + * @param {Array} contacts + * @returns {Object|null} + */ + pickLeadershipContact(contacts) { + const withDiscord = contacts.filter(contact => contact.discordId && contact.discordId.trim()); + if (withDiscord.length === 0) { + return null; + } + + const lead = withDiscord.find(contact => /lead/i.test(contact.role || '')); + return lead || withDiscord[0]; + } + + /** + * Resolve who should receive the approval DM for a confirmer assignment + * @param {string} confirmerName - Value from the sheet (person or group label) + * @returns {Promise} + */ + async getApproverForConfirmer(confirmerName) { + const normalized = this.normalizeConfirmerName(confirmerName); + + if (normalized.includes('ec/bd') || normalized.includes('ec / bd')) { + const ecContact = this.pickLeadershipContact(await this.getContacts('EC')); + if (ecContact) { + return ecContact; + } + } + + if (normalized.includes('anyone') && normalized.includes('ec')) { + const ecContact = this.pickLeadershipContact(await this.getContacts('EC')); + if (ecContact) { + return ecContact; + } + } + + return this.getContactByName(confirmerName); + } + /** * Log hours request to Google Sheets * @param {string} discordUserId - User's Discord ID