Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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:**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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/
Expand All @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion events/interactionCreate.js
Original file line number Diff line number Diff line change
@@ -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_', '');
Expand Down
6 changes: 6 additions & 0 deletions events/modalSubmit.js
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down
21 changes: 19 additions & 2 deletions events/ready.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -43,7 +44,7 @@ module.exports = {
if (memberCache.isInitialLoad) {
memberCache.isInitialLoad = false;
}
}
}
catch (error) {
console.error('⚠️ Failed to fetch guild members:', error.message);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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)');
}
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
42 changes: 42 additions & 0 deletions scripts/debug-pending.js
Original file line number Diff line number Diff line change
@@ -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);
}
})();
57 changes: 57 additions & 0 deletions scripts/print-row-3109.js
Original file line number Diff line number Diff line change
@@ -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);
}
})();
Loading