• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22536363373

01 Mar 2026 04:59AM UTC coverage: 90.104% (-0.2%) from 90.276%
22536363373

push

github

BillChirico
fix: resolve CI failures on main (lint, coverage, secret scanning)

- Migrate biome.json schema to 2.4.4 and fix formatting/lint violations
- Replace isNaN with Number.isNaN in db.js
- Remove unused getConfig import in ticket tests
- Fix import ordering in scheduler and reload tests
- Fix noArrayIndexKey lint in config-editor.tsx
- Add welcome command and scheduler tick tests to meet 85% branch threshold
- Allowlist historical false positive commit in .gitleaks.toml

4581 of 5384 branches covered (85.09%)

Branch coverage included in aggregate %.

4 of 4 new or added lines in 2 files covered. (100.0%)

106 existing lines in 14 files now uncovered.

7856 of 8419 relevant lines covered (93.31%)

49.06 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.24
/src/modules/pollHandler.js
1
/**
2
 * Poll Handler Module
3
 * Handles button interactions for poll voting and auto-close logic.
4
 *
5
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/47
6
 */
7

8
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
9
import { getPool } from '../db.js';
10
import { info, error as logError } from '../logger.js';
11
import { safeReply } from '../utils/safeSend.js';
12

13
const POLL_COLOR = 0x5865f2;
37✔
14

15
/**
16
 * Build the poll embed showing current vote counts.
17
 *
18
 * @param {object} poll - Poll row from the database
19
 * @returns {EmbedBuilder}
20
 */
21
export function buildPollEmbed(poll) {
22
  const options = poll.options;
14✔
23
  const votes = poll.votes || {};
14!
24

25
  // Count votes per option
26
  const voteCounts = new Array(options.length).fill(0);
14✔
27
  for (const indices of Object.values(votes)) {
14✔
28
    for (const idx of indices) {
8✔
29
      if (idx >= 0 && idx < options.length) {
9!
30
        voteCounts[idx]++;
9✔
31
      }
32
    }
33
  }
34

35
  const totalVotes = voteCounts.reduce((a, b) => a + b, 0);
40✔
36
  const voterCount = Object.keys(votes).length;
14✔
37

38
  const lines = options.map((opt, i) => {
14✔
39
    const count = voteCounts[i];
40✔
40
    const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
40✔
41
    const filled = Math.round(pct / 10);
40✔
42
    const bar = '▓'.repeat(filled) + '░'.repeat(10 - filled);
40✔
43
    return `**${i + 1}.** ${opt}\n${bar} ${pct}% (${count} vote${count !== 1 ? 's' : ''})`;
40✔
44
  });
45

46
  const description = lines.join('\n\n');
14✔
47

48
  let footer = `Poll #${poll.id}`;
14✔
49
  if (poll.closed) {
14✔
50
    footer += ' • Closed';
3✔
51
  } else if (poll.closes_at) {
11✔
52
    const ts = Math.floor(new Date(poll.closes_at).getTime() / 1000);
1✔
53
    footer += ` • Closes <t:${ts}:R>`;
1✔
54
  } else {
55
    footer += ' • No time limit';
10✔
56
  }
57
  footer += ` • ${voterCount} voter${voterCount !== 1 ? 's' : ''}`;
14✔
58

59
  const titleQuestion =
60
    poll.question.length > 253 ? `${poll.question.slice(0, 250)}...` : poll.question;
14!
61
  const embed = new EmbedBuilder()
14✔
62
    .setTitle(`📊 ${titleQuestion}`)
63
    .setDescription(description)
64
    .setColor(POLL_COLOR)
65
    .setFooter({ text: footer });
66

67
  if (poll.multi_vote) {
14✔
68
    embed.setDescription(`${description}\n\n*Multiple votes allowed*`);
2✔
69
  }
70

71
  return embed;
14✔
72
}
73

74
/**
75
 * Build button rows for poll options.
76
 *
77
 * @param {number} pollId - Poll ID
78
 * @param {string[]} options - Poll option labels
79
 * @param {boolean} disabled - Whether buttons should be disabled
80
 * @returns {ActionRowBuilder[]}
81
 */
82
export function buildPollButtons(pollId, options, disabled = false) {
9✔
83
  const rows = [];
9✔
84
  let currentRow = new ActionRowBuilder();
9✔
85

86
  for (let i = 0; i < options.length; i++) {
9✔
87
    if (i > 0 && i % 5 === 0) {
34✔
88
      rows.push(currentRow);
2✔
89
      currentRow = new ActionRowBuilder();
2✔
90
    }
91

92
    const prefix = `${i + 1}. `;
34✔
93
    const maxLen = 80 - prefix.length;
34✔
94
    const label = options[i].length > maxLen ? `${options[i].slice(0, maxLen - 3)}...` : options[i];
34!
95
    currentRow.addComponents(
34✔
96
      new ButtonBuilder()
97
        .setCustomId(`poll_vote_${pollId}_${i}`)
98
        .setLabel(`${prefix}${label}`)
99
        .setStyle(ButtonStyle.Primary)
100
        .setDisabled(disabled),
101
    );
102
  }
103

104
  rows.push(currentRow);
9✔
105
  return rows;
9✔
106
}
107

108
/**
109
 * Handle a poll vote button click.
110
 *
111
 * @param {import('discord.js').ButtonInteraction} interaction
112
 */
113
export async function handlePollVote(interaction) {
114
  const match = interaction.customId.match(/^poll_vote_(\d+)_(\d+)$/);
7✔
115
  if (!match) return;
7!
116

117
  const pollId = Number.parseInt(match[1], 10);
7✔
118
  const optionIndex = Number.parseInt(match[2], 10);
7✔
119

120
  const pool = getPool();
7✔
121
  const client = await pool.connect();
7✔
122

123
  let poll;
124
  let votes;
125
  let removed = false;
7✔
126
  let optionName;
127

128
  try {
7✔
129
    await client.query('BEGIN');
7✔
130

131
    // Lock the row to prevent concurrent vote modifications
132
    const { rows } = await client.query(
7✔
133
      'SELECT * FROM polls WHERE id = $1 AND guild_id = $2 FOR UPDATE',
134
      [pollId, interaction.guildId],
135
    );
136

137
    if (rows.length === 0) {
7✔
138
      await client.query('ROLLBACK');
1✔
139
      await safeReply(interaction, {
1✔
140
        content: '❌ This poll no longer exists.',
141
        ephemeral: true,
142
      });
143
      return;
1✔
144
    }
145

146
    poll = rows[0];
6✔
147

148
    if (poll.closed) {
6✔
149
      await client.query('ROLLBACK');
1✔
150
      await safeReply(interaction, {
1✔
151
        content: '❌ This poll is closed.',
152
        ephemeral: true,
153
      });
154
      return;
1✔
155
    }
156

157
    // Reject votes after closes_at
158
    if (poll.closes_at && new Date(poll.closes_at) <= new Date()) {
5!
159
      await client.query('ROLLBACK');
×
160
      await safeReply(interaction, {
×
161
        content: '❌ This poll has expired.',
162
        ephemeral: true,
163
      });
164
      return;
×
165
    }
166

167
    if (optionIndex < 0 || optionIndex >= poll.options.length) {
5!
168
      await client.query('ROLLBACK');
×
169
      await safeReply(interaction, {
×
170
        content: '❌ Invalid option.',
171
        ephemeral: true,
172
      });
173
      return;
×
174
    }
175

176
    const userId = interaction.user.id;
5✔
177
    votes = poll.votes || {};
5!
178
    const userVotes = votes[userId] || [];
7✔
179
    optionName = poll.options[optionIndex];
7✔
180

181
    if (poll.multi_vote) {
7✔
182
      if (userVotes.includes(optionIndex)) {
2✔
183
        votes[userId] = userVotes.filter((i) => i !== optionIndex);
2✔
184
        if (votes[userId].length === 0) delete votes[userId];
1!
185
        removed = true;
1✔
186
      } else {
187
        votes[userId] = [...userVotes, optionIndex];
1✔
188
      }
189
    } else {
190
      if (userVotes.includes(optionIndex)) {
3✔
191
        delete votes[userId];
1✔
192
        removed = true;
1✔
193
      } else {
194
        votes[userId] = [optionIndex];
2✔
195
      }
196
    }
197

198
    await client.query('UPDATE polls SET votes = $1 WHERE id = $2', [
5✔
199
      JSON.stringify(votes),
200
      pollId,
201
    ]);
202
    await client.query('COMMIT');
5✔
203
  } catch (err) {
204
    await client.query('ROLLBACK').catch(() => {});
×
205
    throw err;
×
206
  } finally {
207
    client.release();
7✔
208
  }
209

210
  // Update the poll object for embed rebuild
211
  poll.votes = votes;
5✔
212

213
  // Update the embed on the message
214
  try {
5✔
215
    const embed = buildPollEmbed(poll);
5✔
216
    await interaction.message.edit({
5✔
217
      embeds: [embed],
218
    });
219
  } catch (err) {
220
    logError('Failed to update poll embed', { pollId, error: err.message });
×
221
  }
222

223
  // Ephemeral confirmation
224
  const emoji = removed ? '❌' : '✅';
5✔
225
  const action = removed ? 'Vote removed for' : 'Voted for';
7✔
226
  await safeReply(interaction, {
7✔
227
    content: `${emoji} ${action} **${optionName}**`,
228
    ephemeral: true,
229
  });
230

231
  info('Poll vote recorded', {
5✔
232
    pollId,
233
    userId: interaction.user.id,
234
    optionIndex,
235
    removed,
236
    anonymous: poll.anonymous,
237
  });
238
}
239

240
/**
241
 * Close a poll: update DB, edit embed, disable buttons.
242
 *
243
 * @param {number} pollId - Poll ID
244
 * @param {import('discord.js').Client} client - Discord client
245
 * @returns {Promise<boolean>} Whether the poll was successfully closed
246
 */
247
export async function closePoll(pollId, client) {
248
  const pool = getPool();
2✔
249

250
  const { rows } = await pool.query(
2✔
251
    'UPDATE polls SET closed = true WHERE id = $1 AND closed = false RETURNING *',
252
    [pollId],
253
  );
254

255
  if (rows.length === 0) return false;
2!
256

257
  const poll = rows[0];
2✔
258

259
  try {
2✔
260
    const channel = await client.channels.fetch(poll.channel_id).catch(() => null);
2✔
261
    if (channel && poll.message_id) {
2!
262
      const message = await channel.messages.fetch(poll.message_id).catch(() => null);
2✔
263
      if (message) {
2!
264
        const embed = buildPollEmbed(poll);
2✔
265
        const buttons = buildPollButtons(poll.id, poll.options, true);
2✔
266
        await message.edit({ embeds: [embed], components: buttons });
2✔
267
      }
268
    }
269
  } catch (err) {
270
    logError('Failed to edit closed poll message', { pollId, error: err.message });
×
271
  }
272

273
  info('Poll closed', { pollId, guildId: poll.guild_id });
2✔
274
  return true;
2✔
275
}
276

277
/**
278
 * Check for and close expired polls.
279
 *
280
 * @param {import('discord.js').Client} client - Discord client
281
 */
282
export async function closeExpiredPolls(client) {
UNCOV
283
  try {
×
UNCOV
284
    const pool = getPool();
×
UNCOV
285
    const { rows } = await pool.query(
×
286
      'SELECT id FROM polls WHERE closed = false AND closes_at IS NOT NULL AND closes_at <= NOW()',
287
    );
288

UNCOV
289
    for (const row of rows) {
×
290
      try {
×
291
        await closePoll(row.id, client);
×
292
      } catch (err) {
293
        logError('Failed to close expired poll', { pollId: row.id, error: err.message });
×
294
      }
295
    }
296
  } catch (err) {
297
    logError('Poll expiry check failed', { error: err.message });
×
298
  }
299
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc