From ba8006bf052d989af0f33a5494080630cee7dc14 Mon Sep 17 00:00:00 2001 From: Jupiterian Date: Sat, 23 May 2026 12:55:59 -0700 Subject: [PATCH 1/5] sheets.js get pending hour requests --- utils/sheets.js | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/utils/sheets.js b/utils/sheets.js index 657d32d..376959b 100644 --- a/utils/sheets.js +++ b/utils/sheets.js @@ -229,6 +229,73 @@ class SheetsManager { }; } + /** + * Get hour verification requests that are still awaiting approval + * @returns {Promise} Object with array of pending requests, or null on sheet error + */ + async getNewHourVerificationRequests() { + const eventsSheetId = process.env.EVENTS_SHEET_ID; + const response = await this.safeApiCall( + () => this.sheets.spreadsheets.values.get({ + spreadsheetId: eventsSheetId, + range: '\'Hour Verification\'!A:I', + }), + 'getNewHourVerificationRequests', + ); + + if (!response || !response.data || !response.data.values) { + console.error('❌ Failed to get hour verification data from Google Sheets'); + return null; + } + + const rows = response.data.values; + if (rows.length <= 2) { + return { requests: [] }; + } + + const pendingRequests = []; + for (let i = 2; i < rows.length; i++) { + const row = rows[i]; + const rowName = row[0] ? row[0].trim() : ''; + if (!rowName) { + continue; + } + + const verdict = row[2] ? row[2].trim() : ''; + if (!this.isPendingHourVerdict(verdict)) { + continue; + } + + pendingRequests.push({ + rowNumber: i + 1, + name: rowName, + hours: row[1] || 'N/A', + verdict: verdict || 'Pending', + department: row[3] || 'N/A', + date: row[4] || 'N/A', + type: row[7] || 'N/A', + description: row[8] || 'N/A', + }); + } + + pendingRequests.sort((a, b) => b.rowNumber - a.rowNumber); + + return { requests: pendingRequests }; + } + + /** + * Whether an Hour Verification verdict still needs approver action + * @param {string} verdict - Raw verdict cell value + * @returns {boolean} + */ + isPendingHourVerdict(verdict) { + const normalized = (verdict || '').trim().toLowerCase(); + if (!normalized) { + return true; + } + return normalized === 'pending' || normalized === 'unverified'; + } + /** * Get upcoming events, optionally filtered by department * Sheet is organized by COLUMNS (each column = one event date) From 25a46771df7be7b3b702e45f1c1d2d20bb876bf7 Mon Sep 17 00:00:00 2001 From: Jupiterian Date: Sat, 23 May 2026 13:05:47 -0700 Subject: [PATCH 2/5] initial feature code --- .env.example | 6 + CLAUDE.md | 3 + README.md | 40 ++++ events/interactionCreate.js | 83 +++++++- events/ready.js | 21 +- index.js | 1 + utils/hourApprovalSync.js | 380 ++++++++++++++++++++++++++++++++++++ utils/sheets.js | 89 ++++++++- 8 files changed, 618 insertions(+), 5 deletions(-) create mode 100644 utils/hourApprovalSync.js diff --git a/.env.example b/.env.example index 7359a3f..70ab886 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,12 @@ 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 + # 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..7857a4e 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 `events/interactionCreate.js` (`hour_approve_*`, `hour_decline_*`). Approve writes **Approved** to column C and the leadership name to column F; Decline writes **Denied** to column C (`updateHourVerificationVerdict` in `utils/sheets.js`). + ### 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..f5296e0 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,29 @@ 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/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 department (`LEADERSHIP_SHEET_ID`, Discord User ID required) +3. Approver receives a DM with request details and **Approve** / **Decline** buttons +4. **Approve** → Column C = `Approved`, Column F = approver's name from the Leadership sheet +5. **Decline** → Column C = `Denied` + +**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 +430,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 +486,16 @@ The bot expects the following sheets: - Column A: Name - Column K: Total Hours +- **Tab:** `Hour Verification` + - Column A: Name + - Column B: Hours + - Column C: Verdict (Approved/Denied/Pending/Unverified) + - Column D: Department (matched to Leadership sheet for approver lookup) + - Column E: Date (used for lookback filtering) + - Column F: Approver name (written when approved via Discord) + - Column H: Type of task + - Column I: Description + - **Tab:** `Membership Status` - Column A: Name (starts at row 10) - Column B: Status (Member, New Member, Paused, Not a Member, Unknown) @@ -502,6 +539,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 +643,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 +670,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..5a1a500 100644 --- a/events/interactionCreate.js +++ b/events/interactionCreate.js @@ -1,8 +1,27 @@ -const { Collection, Events, MessageFlags, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = require('discord.js'); +const { + Collection, + Events, + MessageFlags, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, + EmbedBuilder, +} = require('discord.js'); +const sheetsManager = require('../utils/sheets'); +const { clearHourApprovalSession } = require('../utils/hourApprovalSync'); +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_', ''); @@ -168,6 +187,68 @@ module.exports = { return; } + // Handle hour approval DM buttons + if (interaction.isButton() + && (interaction.customId.startsWith('hour_approve_') + || interaction.customId.startsWith('hour_decline_'))) { + const isApprove = interaction.customId.startsWith('hour_approve_'); + const rowNumber = parseInt( + interaction.customId.replace(isApprove ? 'hour_approve_' : 'hour_decline_', ''), + 10, + ); + + if (Number.isNaN(rowNumber)) { + return interaction.reply({ + content: '❌ Invalid hour approval button.', + flags: MessageFlags.Ephemeral, + }); + } + + const pending = interaction.client.hourApprovalPending?.get(rowNumber); + if (!pending) { + return interaction.reply({ + content: '❌ This hour approval session has expired or was already completed.', + flags: MessageFlags.Ephemeral, + }); + } + + if (interaction.user.id !== pending.approverId) { + return interaction.reply({ + content: '❌ Only the assigned approver can use these buttons.', + flags: MessageFlags.Ephemeral, + }); + } + + const verdict = isApprove ? 'Approved' : 'Denied'; + const approverName = isApprove ? pending.approverSheetName : null; + const success = await sheetsManager.updateHourVerificationVerdict( + rowNumber, + verdict, + approverName, + ); + + if (!success) { + return interaction.reply({ + content: '❌ Failed to update the Hour Verification sheet. Please try again or update manually.', + flags: MessageFlags.Ephemeral, + }); + } + + clearHourApprovalSession(interaction.client, rowNumber); + + const resultEmbed = EmbedBuilder.from(interaction.message.embeds[0]) + .setColor(isApprove ? 0x57F287 : 0xED4245) + .setTitle(isApprove ? '✅ Hour Request Approved' : '❌ Hour Request Declined') + .setDescription( + isApprove + ? `Recorded as **Approved** under **${pending.approverSheetName}**.` + : 'Recorded as **Denied** on the Hour Verification sheet.', + ); + + await interaction.update({ embeds: [resultEmbed], components: [] }); + return; + } + // Handle autocomplete interactions if (interaction.isAutocomplete()) { const command = interaction.client.commands.get(interaction.commandName); 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/utils/hourApprovalSync.js b/utils/hourApprovalSync.js new file mode 100644 index 0000000..9488cac --- /dev/null +++ b/utils/hourApprovalSync.js @@ -0,0 +1,380 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} = require('discord.js'); +const sheetsManager = require('./sheets'); + +const notifiedFilePath = path.join(__dirname, '..', 'data', 'hour-approval-notified.json'); +const DEFAULT_LOOKBACK_DAYS = 30; +const DEFAULT_POLL_MINUTES = 5; +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 {Array} contacts + * @returns {Object|null} + */ +function pickDepartmentApprover(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]; +} + +/** + * @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: '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_decline_${rowNumber}`) + .setLabel('Decline') + .setStyle(ButtonStyle.Danger) + .setEmoji('❌'), + ); +} + +/** + * @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 department = request.department && request.department !== 'N/A' + ? request.department.trim() + : null; + + if (!department) { + console.warn( + `[HourApproval] Row ${request.rowNumber} has no department — cannot find approver`, + ); + continue; + } + + const contacts = await sheetsManager.getContacts(department); + const approver = pickDepartmentApprover(contacts); + + if (!approver) { + console.warn( + `[HourApproval] No leadership contact with Discord ID for department "${department}" ` + + `(row ${request.rowNumber})`, + ); + 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 match = approveMatch || declineMatch; + + if (!match) { + return false; + } + + const rowNumber = parseInt(match[1], 10); + const isApprove = Boolean(approveMatch); + + if (!interaction.client.hourApprovalPending?.has(rowNumber)) { + await interaction.reply({ + content: '❌ This approval request has expired or was already handled.', + ephemeral: true, + }); + return true; + } + + const pending = interaction.client.hourApprovalPending.get(rowNumber); + + if (interaction.user.id !== pending.approverId) { + await interaction.reply({ + content: '❌ Only the assigned department approver can use these buttons.', + ephemeral: true, + }); + return true; + } + + await interaction.deferUpdate(); + + const freshData = await sheetsManager.getNewHourVerificationRequests( + parseInt(process.env.HOUR_APPROVAL_LOOKBACK_DAYS, 10) || DEFAULT_LOOKBACK_DAYS, + ); + const stillPending = freshData?.requests.some(req => req.rowNumber === rowNumber); + + if (!stillPending) { + 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 approverName = pending.approverSheetName + || interaction.member?.displayName + || interaction.user.displayName + || interaction.user.username; + const verdict = isApprove ? 'Approved' : 'Denied'; + const success = await sheetsManager.updateHourVerificationVerdict( + rowNumber, + verdict, + isApprove ? approverName : null, + ); + + 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 + ? `Marked **Approved** in the sheet (approver: **${approverName}**).` + : 'Marked **Denied** in the sheet.', + ); + + await interaction.editReply({ + content: null, + embeds: [embed], + components: [], + }); + + console.log( + `[HourApproval] Row ${rowNumber} ${verdict.toLowerCase()} by ${interaction.user.tag}`, + ); + + return true; +} + +module.exports = { + startHourApprovalSync, + syncHourApprovalRequests, + handleHourApprovalButton, + buildHourApprovalEmbed, + clearHourApprovalSession, + loadNotifiedRows, + saveNotifiedRows, +}; diff --git a/utils/sheets.js b/utils/sheets.js index 376959b..31b0235 100644 --- a/utils/sheets.js +++ b/utils/sheets.js @@ -231,9 +231,10 @@ class SheetsManager { /** * 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() { + async getNewHourVerificationRequests(lookbackDays = 30) { const eventsSheetId = process.env.EVENTS_SHEET_ID; const response = await this.safeApiCall( () => this.sheets.spreadsheets.values.get({ @@ -253,6 +254,10 @@ class SheetsManager { return { requests: [] }; } + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - lookbackDays); + cutoff.setHours(0, 0, 0, 0); + const pendingRequests = []; for (let i = 2; i < rows.length; i++) { const row = rows[i]; @@ -266,13 +271,22 @@ class SheetsManager { continue; } + const dateValue = row[4] || ''; + const parsedDate = this.parseHourVerificationDate(dateValue); + if (!parsedDate) { + continue; + } + if (parsedDate < cutoff) { + continue; + } + pendingRequests.push({ rowNumber: i + 1, name: rowName, hours: row[1] || 'N/A', verdict: verdict || 'Pending', department: row[3] || 'N/A', - date: row[4] || 'N/A', + date: dateValue || 'N/A', type: row[7] || 'N/A', description: row[8] || 'N/A', }); @@ -283,6 +297,77 @@ class SheetsManager { return { requests: pendingRequests }; } + /** + * 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 verdict (and optional approver name) for an hour verification row + * @param {number} rowNumber - 1-indexed sheet row number + * @param {string} verdict - Verdict text (e.g. Approved, Denied) + * @param {string|null} approverName - Leadership name written to column F when approved + * @returns {Promise} Success status + */ + async updateHourVerificationVerdict(rowNumber, verdict, approverName = null) { + const eventsSheetId = process.env.EVENTS_SHEET_ID; + + const verdictResponse = await this.safeApiCall( + () => this.sheets.spreadsheets.values.update({ + spreadsheetId: eventsSheetId, + range: `'Hour Verification'!C${rowNumber}`, + valueInputOption: 'USER_ENTERED', + resource: { + values: [[verdict]], + }, + }), + 'updateHourVerificationVerdict (verdict)', + ); + + if (!verdictResponse) { + return false; + } + + if (approverName && verdict.toLowerCase() === 'approved') { + const approverResponse = await this.safeApiCall( + () => this.sheets.spreadsheets.values.update({ + spreadsheetId: eventsSheetId, + range: `'Hour Verification'!F${rowNumber}`, + valueInputOption: 'USER_ENTERED', + resource: { + values: [[approverName]], + }, + }), + 'updateHourVerificationVerdict (approver)', + ); + + if (!approverResponse) { + return false; + } + } + + return true; + } + /** * Whether an Hour Verification verdict still needs approver action * @param {string} verdict - Raw verdict cell value From f9abacc3837642fc969296fe02996bd8577ebb41 Mon Sep 17 00:00:00 2001 From: Jupiterian Date: Sun, 24 May 2026 00:18:26 -0700 Subject: [PATCH 3/5] debug (with AI) --- utils/hourApprovalSync.js | 28 ++++++--------------- utils/sheets.js | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/utils/hourApprovalSync.js b/utils/hourApprovalSync.js index 9488cac..0787b9d 100644 --- a/utils/hourApprovalSync.js +++ b/utils/hourApprovalSync.js @@ -45,20 +45,6 @@ function saveNotifiedRows(notifiedRows) { } } -/** - * @param {Array} contacts - * @returns {Object|null} - */ -function pickDepartmentApprover(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]; -} - /** * @param {Object} request * @returns {EmbedBuilder} @@ -71,6 +57,7 @@ function buildHourApprovalEmbed(request) { .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 }, @@ -202,23 +189,22 @@ async function syncHourApprovalRequests(client) { continue; } - const department = request.department && request.department !== 'N/A' - ? request.department.trim() + const confirmer = request.confirmer && request.confirmer !== 'N/A' + ? request.confirmer.trim() : null; - if (!department) { + if (!confirmer) { console.warn( - `[HourApproval] Row ${request.rowNumber} has no department — cannot find approver`, + `[HourApproval] Row ${request.rowNumber} has no confirmer — cannot find approver`, ); continue; } - const contacts = await sheetsManager.getContacts(department); - const approver = pickDepartmentApprover(contacts); + const approver = await sheetsManager.getContactByName(confirmer); if (!approver) { console.warn( - `[HourApproval] No leadership contact with Discord ID for department "${department}" ` + `[HourApproval] No leadership contact with Discord ID for confirmer "${confirmer}" ` + `(row ${request.rowNumber})`, ); continue; diff --git a/utils/sheets.js b/utils/sheets.js index 31b0235..a9732da 100644 --- a/utils/sheets.js +++ b/utils/sheets.js @@ -286,6 +286,7 @@ class SheetsManager { hours: row[1] || 'N/A', verdict: verdict || 'Pending', department: row[3] || 'N/A', + confirmer: row[5] || 'N/A', date: dateValue || 'N/A', type: row[7] || 'N/A', description: row[8] || 'N/A', @@ -544,6 +545,57 @@ 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; + } + /** * Log hours request to Google Sheets * @param {string} discordUserId - User's Discord ID From 367621bd17d73925ee63f283dff91cc78bfc4d61 Mon Sep 17 00:00:00 2001 From: Jupiterian Date: Sun, 24 May 2026 12:10:55 -0700 Subject: [PATCH 4/5] WIP (still needs debugging) Co-authored-by: Copilot --- .env.example | 5 + CLAUDE.md | 2 +- README.md | 19 +- events/interactionCreate.js | 65 ----- events/modalSubmit.js | 6 + scripts/debug-pending.js | 42 +++ scripts/print-row-3109.js | 57 ++++ utils/hourApprovalSync.js | 241 +++++++++++++++-- utils/sheets.js | 526 ++++++++++++++++++++++++++++++++---- 9 files changed, 808 insertions(+), 155 deletions(-) create mode 100644 scripts/debug-pending.js create mode 100644 scripts/print-row-3109.js diff --git a/.env.example b/.env.example index 70ab886..c343dfd 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,11 @@ 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. diff --git a/CLAUDE.md b/CLAUDE.md index 7857a4e..0ffc7d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Singleton persisted to `data/member-cache.json`. Populated by `events/ready.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 `events/interactionCreate.js` (`hour_approve_*`, `hour_decline_*`). Approve writes **Approved** to column C and the leadership name to column F; Decline writes **Denied** to column C (`updateHourVerificationVerdict` in `utils/sheets.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 f5296e0..25969ae 100644 --- a/README.md +++ b/README.md @@ -352,14 +352,17 @@ When enabled, the bot polls the **Hour Verification** sheet and DMs department l - `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/Decline buttons stay active (default: 7 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 department (`LEADERSHIP_SHEET_ID`, Discord User ID required) -3. Approver receives a DM with request details and **Approve** / **Decline** buttons -4. **Approve** → Column C = `Approved`, Column F = approver's name from the Leadership sheet -5. **Decline** → Column C = `Denied` +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 @@ -489,12 +492,12 @@ The bot expects the following sheets: - **Tab:** `Hour Verification` - Column A: Name - Column B: Hours - - Column C: Verdict (Approved/Denied/Pending/Unverified) - - Column D: Department (matched to Leadership sheet for approver lookup) + - Column D: Department - Column E: Date (used for lookback filtering) - - Column F: Approver name (written when approved via Discord) + - 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) diff --git a/events/interactionCreate.js b/events/interactionCreate.js index 5a1a500..90ad826 100644 --- a/events/interactionCreate.js +++ b/events/interactionCreate.js @@ -6,10 +6,7 @@ const { TextInputBuilder, TextInputStyle, ActionRowBuilder, - EmbedBuilder, } = require('discord.js'); -const sheetsManager = require('../utils/sheets'); -const { clearHourApprovalSession } = require('../utils/hourApprovalSync'); const { handleHourApprovalButton } = require('../utils/hourApprovalSync'); module.exports = { @@ -187,68 +184,6 @@ module.exports = { return; } - // Handle hour approval DM buttons - if (interaction.isButton() - && (interaction.customId.startsWith('hour_approve_') - || interaction.customId.startsWith('hour_decline_'))) { - const isApprove = interaction.customId.startsWith('hour_approve_'); - const rowNumber = parseInt( - interaction.customId.replace(isApprove ? 'hour_approve_' : 'hour_decline_', ''), - 10, - ); - - if (Number.isNaN(rowNumber)) { - return interaction.reply({ - content: '❌ Invalid hour approval button.', - flags: MessageFlags.Ephemeral, - }); - } - - const pending = interaction.client.hourApprovalPending?.get(rowNumber); - if (!pending) { - return interaction.reply({ - content: '❌ This hour approval session has expired or was already completed.', - flags: MessageFlags.Ephemeral, - }); - } - - if (interaction.user.id !== pending.approverId) { - return interaction.reply({ - content: '❌ Only the assigned approver can use these buttons.', - flags: MessageFlags.Ephemeral, - }); - } - - const verdict = isApprove ? 'Approved' : 'Denied'; - const approverName = isApprove ? pending.approverSheetName : null; - const success = await sheetsManager.updateHourVerificationVerdict( - rowNumber, - verdict, - approverName, - ); - - if (!success) { - return interaction.reply({ - content: '❌ Failed to update the Hour Verification sheet. Please try again or update manually.', - flags: MessageFlags.Ephemeral, - }); - } - - clearHourApprovalSession(interaction.client, rowNumber); - - const resultEmbed = EmbedBuilder.from(interaction.message.embeds[0]) - .setColor(isApprove ? 0x57F287 : 0xED4245) - .setTitle(isApprove ? '✅ Hour Request Approved' : '❌ Hour Request Declined') - .setDescription( - isApprove - ? `Recorded as **Approved** under **${pending.approverSheetName}**.` - : 'Recorded as **Denied** on the Hour Verification sheet.', - ); - - await interaction.update({ embeds: [resultEmbed], components: [] }); - return; - } - // Handle autocomplete interactions if (interaction.isAutocomplete()) { const command = interaction.client.commands.get(interaction.commandName); 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/scripts/debug-pending.js b/scripts/debug-pending.js new file mode 100644 index 0000000..6c9fe87 --- /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') }); +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 index 0787b9d..bb2f1c3 100644 --- a/utils/hourApprovalSync.js +++ b/utils/hourApprovalSync.js @@ -5,6 +5,9 @@ const { ActionRowBuilder, ButtonBuilder, ButtonStyle, + ModalBuilder, + TextInputBuilder, + TextInputStyle, } = require('discord.js'); const sheetsManager = require('./sheets'); @@ -80,6 +83,11 @@ function buildApprovalButtons(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') @@ -88,6 +96,60 @@ function buildApprovalButtons(rowNumber) { ); } +/** + * @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 @@ -200,12 +262,12 @@ async function syncHourApprovalRequests(client) { continue; } - const approver = await sheetsManager.getContactByName(confirmer); + const approver = await sheetsManager.getApproverForConfirmer(confirmer); if (!approver) { console.warn( `[HourApproval] No leadership contact with Discord ID for confirmer "${confirmer}" ` - + `(row ${request.rowNumber})`, + + `(row ${request.rowNumber}). For group labels like "Anyone on the EC", add EC members with Discord IDs on the Leadership sheet.`, ); continue; } @@ -267,41 +329,52 @@ async function handleHourApprovalButton(interaction) { const approveMatch = interaction.customId.match(/^hour_approve_(\d+)$/); const declineMatch = interaction.customId.match(/^hour_decline_(\d+)$/); - const match = approveMatch || declineMatch; + const changeMatch = interaction.customId.match(/^hour_change_(\d+)$/); + const match = approveMatch || declineMatch || changeMatch; if (!match) { return false; } const rowNumber = parseInt(match[1], 10); - const isApprove = Boolean(approveMatch); - - if (!interaction.client.hourApprovalPending?.has(rowNumber)) { - await interaction.reply({ - content: '❌ This approval request has expired or was already handled.', - ephemeral: true, - }); + const pending = await getHourApprovalSession(interaction, rowNumber); + if (!pending) { return true; } - const pending = interaction.client.hourApprovalPending.get(rowNumber); - - if (interaction.user.id !== pending.approverId) { - await interaction.reply({ - content: '❌ Only the assigned department approver can use these buttons.', - ephemeral: 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 freshData = await sheetsManager.getNewHourVerificationRequests( - parseInt(process.env.HOUR_APPROVAL_LOOKBACK_DAYS, 10) || DEFAULT_LOOKBACK_DAYS, - ); - const stillPending = freshData?.requests.some(req => req.rowNumber === rowNumber); + const confirmerColumnIndex = pending.request.confirmerColumnIndex; + if (confirmerColumnIndex === undefined || confirmerColumnIndex === null) { + await interaction.editReply({ + content: '❌ Could not resolve this confirmer\'s column in the sheet.', + embeds: interaction.message.embeds, + components: [], + }); + return true; + } - if (!stillPending) { + if (!await isHourRequestStillPending(rowNumber, confirmerColumnIndex)) { clearHourApprovalSession(interaction.client, rowNumber); await interaction.editReply({ content: '❌ This request is no longer pending in the sheet.', @@ -311,15 +384,18 @@ async function handleHourApprovalButton(interaction) { return true; } + const isApprove = Boolean(approveMatch); const approverName = pending.approverSheetName || interaction.member?.displayName || interaction.user.displayName || interaction.user.username; - const verdict = isApprove ? 'Approved' : 'Denied'; - const success = await sheetsManager.updateHourVerificationVerdict( + const sheetStatus = isApprove ? 'Approved' : 'Denied'; + const success = await sheetsManager.setConfirmerHourStatus( rowNumber, - verdict, - isApprove ? approverName : null, + confirmerColumnIndex, + sheetStatus, + null, + approverName, ); clearHourApprovalSession(interaction.client, rowNumber); @@ -338,8 +414,8 @@ async function handleHourApprovalButton(interaction) { .setTitle(isApprove ? '✅ Hour Request Approved' : '❌ Hour Request Declined') .setDescription( isApprove - ? `Marked **Approved** in the sheet (approver: **${approverName}**).` - : 'Marked **Denied** in the sheet.', + ? `Set **Approved** in **${pending.request.confirmer}**'s column (by **${approverName}**).` + : `Set **Denied** in **${pending.request.confirmer}**'s column.`, ); await interaction.editReply({ @@ -349,7 +425,113 @@ async function handleHourApprovalButton(interaction) { }); console.log( - `[HourApproval] Row ${rowNumber} ${verdict.toLowerCase()} by ${interaction.user.tag}`, + `[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 (confirmerColumnIndex === undefined || confirmerColumnIndex === null) { + await interaction.editReply({ + content: '❌ Could not resolve this confirmer\'s column in the sheet.', + }); + return true; + } + + 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; @@ -359,6 +541,7 @@ module.exports = { startHourApprovalSync, syncHourApprovalRequests, handleHourApprovalButton, + handleHourApprovalModal, buildHourApprovalEmbed, clearHourApprovalSession, loadNotifiedRows, diff --git a/utils/sheets.js b/utils/sheets.js index a9732da..c6337d8 100644 --- a/utils/sheets.js +++ b/utils/sheets.js @@ -230,53 +230,374 @@ class SheetsManager { } /** - * 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 + * Normalize a confirmer/header name for column lookup + * @param {string} name + * @returns {string} */ - async getNewHourVerificationRequests(lookbackDays = 30) { + 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\'!A:I', + range: `'Hour Verification'!${sheetRange}`, }), - 'getNewHourVerificationRequests', + 'fetchHourVerificationGrid', ); - if (!response || !response.data || !response.data.values) { + 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 = response.data.values; - if (rows.length <= 2) { + const { rows, headerRowCount, dateColumnIndex, confirmerColumnMap } = grid; + if (rows.length <= headerRowCount) { return { requests: [] }; } - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - lookbackDays); - cutoff.setHours(0, 0, 0, 0); + const effectiveLookback = this.normalizeLookbackDays(lookbackDays); + const confirmerFieldColumn = this.getHourVerificationConfirmerFieldColumn(); const pendingRequests = []; - for (let i = 2; i < rows.length; i++) { + for (let i = headerRowCount; i < rows.length; i++) { const row = rows[i]; const rowName = row[0] ? row[0].trim() : ''; if (!rowName) { continue; } - const verdict = row[2] ? row[2].trim() : ''; - if (!this.isPendingHourVerdict(verdict)) { + const dateValue = row[dateColumnIndex] ?? ''; + const parsedDate = this.parseHourVerificationDate(dateValue); + if (!parsedDate) { + continue; + } + if (!this.isDateWithinLookback(parsedDate, effectiveLookback)) { continue; } - const dateValue = row[4] || ''; - const parsedDate = this.parseHourVerificationDate(dateValue); - if (!parsedDate) { + const confirmer = row[confirmerFieldColumn] ? String(row[confirmerFieldColumn]).trim() : ''; + if (!confirmer) { continue; } - if (parsedDate < cutoff) { + + 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; } @@ -284,9 +605,10 @@ class SheetsManager { rowNumber: i + 1, name: rowName, hours: row[1] || 'N/A', - verdict: verdict || 'Pending', + verdict: confirmerStatus || 'Pending', department: row[3] || 'N/A', - confirmer: row[5] || 'N/A', + confirmer, + confirmerColumnIndex, date: dateValue || 'N/A', type: row[7] || 'N/A', description: row[8] || 'N/A', @@ -295,9 +617,51 @@ class SheetsManager { 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 @@ -323,50 +687,72 @@ class SheetsManager { } /** - * Update verdict (and optional approver name) for an hour verification row + * Update the hours value (column B) for an hour verification row * @param {number} rowNumber - 1-indexed sheet row number - * @param {string} verdict - Verdict text (e.g. Approved, Denied) - * @param {string|null} approverName - Leadership name written to column F when approved + * @param {number|string} hours - Hours to write * @returns {Promise} Success status */ - async updateHourVerificationVerdict(rowNumber, verdict, approverName = null) { + async updateHourVerificationHours(rowNumber, hours) { const eventsSheetId = process.env.EVENTS_SHEET_ID; - const verdictResponse = await this.safeApiCall( + const response = await this.safeApiCall( () => this.sheets.spreadsheets.values.update({ spreadsheetId: eventsSheetId, - range: `'Hour Verification'!C${rowNumber}`, + range: `'Hour Verification'!B${rowNumber}`, valueInputOption: 'USER_ENTERED', resource: { - values: [[verdict]], + values: [[hours]], }, }), - 'updateHourVerificationVerdict (verdict)', + 'updateHourVerificationHours', ); - if (!verdictResponse) { - return false; - } - - if (approverName && verdict.toLowerCase() === 'approved') { - const approverResponse = await this.safeApiCall( - () => this.sheets.spreadsheets.values.update({ - spreadsheetId: eventsSheetId, - range: `'Hour Verification'!F${rowNumber}`, - valueInputOption: 'USER_ENTERED', - resource: { - values: [[approverName]], - }, - }), - 'updateHourVerificationVerdict (approver)', - ); + return Boolean(response); + } - if (!approverResponse) { + /** + * 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; } } - return true; + 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); } /** @@ -375,11 +761,7 @@ class SheetsManager { * @returns {boolean} */ isPendingHourVerdict(verdict) { - const normalized = (verdict || '').trim().toLowerCase(); - if (!normalized) { - return true; - } - return normalized === 'pending' || normalized === 'unverified'; + return this.isPendingConfirmerStatus(verdict); } /** @@ -596,6 +978,46 @@ class SheetsManager { 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 From a824763165343580f302201505c51573405782d7 Mon Sep 17 00:00:00 2001 From: Jupiterian Date: Sat, 30 May 2026 19:43:09 -0700 Subject: [PATCH 5/5] commit # 5 --- package.json | 1 + scripts/debug-pending.js | 2 +- utils/hourApprovalSync.js | 17 ----------------- 3 files changed, 2 insertions(+), 18 deletions(-) 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 index 6c9fe87..7d98f01 100644 --- a/scripts/debug-pending.js +++ b/scripts/debug-pending.js @@ -1,6 +1,6 @@ const path = require('path'); const fs = require('fs'); -require('dotenv').config({ path: path.join(__dirname, '..', '.env.tester') }); +require('dotenv').config({ path: path.join(__dirname, '..', '.env.tester'), override: true }); const sheetsManager = require('../utils/sheets'); function loadNotifiedRows() { diff --git a/utils/hourApprovalSync.js b/utils/hourApprovalSync.js index bb2f1c3..bfba5bb 100644 --- a/utils/hourApprovalSync.js +++ b/utils/hourApprovalSync.js @@ -13,7 +13,6 @@ const sheetsManager = require('./sheets'); const notifiedFilePath = path.join(__dirname, '..', 'data', 'hour-approval-notified.json'); const DEFAULT_LOOKBACK_DAYS = 30; -const DEFAULT_POLL_MINUTES = 5; const DEFAULT_SESSION_HOURS = 168; // 7 days /** @@ -365,15 +364,6 @@ async function handleHourApprovalButton(interaction) { await interaction.deferUpdate(); const confirmerColumnIndex = pending.request.confirmerColumnIndex; - if (confirmerColumnIndex === undefined || confirmerColumnIndex === null) { - await interaction.editReply({ - content: '❌ Could not resolve this confirmer\'s column in the sheet.', - embeds: interaction.message.embeds, - components: [], - }); - return true; - } - if (!await isHourRequestStillPending(rowNumber, confirmerColumnIndex)) { clearHourApprovalSession(interaction.client, rowNumber); await interaction.editReply({ @@ -464,13 +454,6 @@ async function handleHourApprovalModal(interaction) { await interaction.deferReply({ ephemeral: true }); const confirmerColumnIndex = pending.request.confirmerColumnIndex; - if (confirmerColumnIndex === undefined || confirmerColumnIndex === null) { - await interaction.editReply({ - content: '❌ Could not resolve this confirmer\'s column in the sheet.', - }); - return true; - } - if (!await isHourRequestStillPending(rowNumber, confirmerColumnIndex)) { clearHourApprovalSession(interaction.client, rowNumber); await interaction.editReply({