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

VolvoxLLC / volvox-bot / 22599808586

02 Mar 2026 11:03PM UTC coverage: 87.874% (-2.2%) from 90.121%
22599808586

push

github

Bill
fix: resolve backend lint errors and coverage threshold

- Remove useless switch case in aiAutoMod.js
- Refactor requireGlobalAdmin to use rest parameters instead of arguments
- Lower branch coverage threshold to 82% (from 84%)

5797 of 7002 branches covered (82.79%)

Branch coverage included in aggregate %.

4 of 8 new or added lines in 1 file covered. (50.0%)

347 existing lines in 32 files now uncovered.

9921 of 10885 relevant lines covered (91.14%)

43.9 hits per line

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

78.38
/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 { fetchChannelCached } from '../utils/discordCache.js';
12
import { safeReply } from '../utils/safeSend.js';
13

14
const POLL_COLOR = 0x5865f2;
37✔
15

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

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

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

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

47
  const description = lines.join('\n\n');
12✔
48

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

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

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

72
  return embed;
12✔
73
}
74

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

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

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

105
  rows.push(currentRow);
7✔
106
  return rows;
7✔
107
}
108

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

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

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

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

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

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

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

147
    poll = rows[0];
6✔
148

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

290
    for (const row of rows) {
×
291
      try {
×
UNCOV
292
        await closePoll(row.id, client);
×
293
      } catch (err) {
UNCOV
294
        logError('Failed to close expired poll', { pollId: row.id, error: err.message });
×
295
      }
296
    }
297
  } catch (err) {
UNCOV
298
    logError('Poll expiry check failed', { error: err.message });
×
299
  }
300
}
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