diff --git a/app/commands/escalate/handlers.ts b/app/commands/escalate/handlers.ts index 921af2c..625145d 100644 --- a/app/commands/escalate/handlers.ts +++ b/app/commands/escalate/handlers.ts @@ -4,25 +4,34 @@ import { ButtonStyle, MessageFlags, PermissionsBitField, + type Message, type MessageComponentInteraction, + type ThreadChannel, } from "discord.js"; +import { client } from "#~/discord/client.server.ts"; import { executeResolution } from "#~/discord/escalationResolver.js"; import { hasModRole } from "#~/helpers/discord.js"; import { parseFlags } from "#~/helpers/escalationVotes.js"; import type { Features } from "#~/helpers/featuresFlags.js"; import { humanReadableResolutions, + votingStrategies, type Resolution, + type VotingStrategy, } from "#~/helpers/modResponse"; import { log } from "#~/helpers/observability"; import { applyRestriction, ban, kick, timeout } from "#~/models/discord.server"; import { + calculateScheduledFor, createEscalation, getEscalation, getVotesForEscalation, recordVote, resolveEscalation, + updateEscalationStrategy, + updateScheduledFor, + type Escalation, } from "#~/models/escalationVotes.server"; import { DEFAULT_QUORUM, @@ -37,7 +46,11 @@ import { buildVoteMessageContent, buildVotesListContent, } from "./strings"; -import { tallyVotes, type VoteTally } from "./voting"; +import { + shouldTriggerEarlyResolution, + tallyVotes, + type VoteTally, +} from "./voting"; export const EscalationHandlers = { // Direct action commands (no voting) @@ -331,16 +344,35 @@ ${buildVotesListContent(tally)}`, const tally = tallyVotes(votes); const flags = parseFlags(escalation.flags); const quorum = flags.quorum; - const quorumReached = tally.leaderCount >= quorum; + const votingStrategy = + (escalation.voting_strategy as VotingStrategy) ?? "simple"; + + // Update scheduled_for based on new vote count + const newScheduledFor = calculateScheduledFor( + escalation.created_at, + tally.totalVotes, + ); + await updateScheduledFor(escalationId, newScheduledFor); - // Check if quorum reached with clear winner - show confirmed state - if (quorumReached && !tally.isTied && tally.leader) { + // Create updated escalation object with new scheduled_for + const updatedEscalation: Escalation = { + ...escalation, + scheduled_for: newScheduledFor, + }; + + const earlyResolution = shouldTriggerEarlyResolution( + tally, + quorum, + votingStrategy, + ); + + // Check if early resolution triggered with clear winner - show confirmed state + if (earlyResolution && !tally.isTied && tally.leader) { await interaction.update({ content: buildConfirmedMessageContent( - escalation.reported_user_id, + updatedEscalation, tally.leader, tally, - escalation.created_at, ), components: [ new ActionRowBuilder().addComponents( @@ -358,17 +390,16 @@ ${buildVotesListContent(tally)}`, await interaction.update({ content: buildVoteMessageContent( modRoleId, - escalation.initiator_id, - escalation.reported_user_id, + votingStrategy, + updatedEscalation, tally, - quorum, - escalation.created_at, ), components: buildVoteButtons( features, - escalationId, + votingStrategy, + updatedEscalation, tally, - quorumReached, + earlyResolution, ), }); }, @@ -397,80 +428,134 @@ ${buildVotesListContent(tally)}`, if (restricted) { features.push("restrict"); } - if (Number(level) >= 1) { - features.push("escalate-level-1"); - } + const guild = await client.guilds.fetch(guildId); + + // Determine voting strategy based on level + const votingStrategy: VotingStrategy | null = + Number(level) >= 1 ? votingStrategies.majority : votingStrategies.simple; - // TODO: if level 0, use default_quorum. if level >=1, count a list of all members with the moderator role and require a majority to vote before a resolution is chosen const quorum = DEFAULT_QUORUM; try { - const emptyTally: VoteTally = { - totalVotes: 0, - byResolution: new Map(), - leader: null, - leaderCount: 0, - isTied: false, - tiedResolutions: [], - }; - - const createdAt = new Date().toISOString(); - const content = { - content: buildVoteMessageContent( - modRoleId, - interaction.user.id, - reportedUserId, - emptyTally, - quorum, - createdAt, - ), - components: buildVoteButtons(features, escalationId, emptyTally, false), - }; - - let voteMessage; + const channel = (await guild.channels.fetch( + interaction.channelId, + )) as ThreadChannel; + let voteMessage: Message; if (Number(level) === 0) { // Send vote message first to get its ID - const channel = interaction.channel; if (!channel || !("send" in channel)) { await interaction.editReply({ content: "Failed to create escalation vote: invalid channel", }); return; } - voteMessage = await channel.send(content); + + const createdAt = new Date().toISOString(); + // Create a temporary escalation-like object for initial message + const tempEscalation: Escalation = { + id: escalationId, + guild_id: guildId, + thread_id: threadId, + vote_message_id: "", // Will be set after message is sent + reported_user_id: reportedUserId, + initiator_id: interaction.user.id, + flags: JSON.stringify({ quorum }), + created_at: createdAt, + resolved_at: null, + resolution: null, + voting_strategy: votingStrategy, + scheduled_for: calculateScheduledFor(createdAt, 0), + }; + const emptyTally: VoteTally = { + totalVotes: 0, + byResolution: new Map(), + leader: null, + leaderCount: 0, + isTied: false, + tiedResolutions: [], + }; + + voteMessage = await channel.send({ + content: buildVoteMessageContent( + modRoleId, + votingStrategy, + tempEscalation, + emptyTally, + ), + components: buildVoteButtons( + features, + votingStrategy, + tempEscalation, + emptyTally, + false, + ), + }); + tempEscalation.vote_message_id = voteMessage.id; + // Now create escalation record with the correct message ID + await createEscalation(tempEscalation); + + // Send notification + await interaction.editReply("Escalation started"); } else { + // Re-escalation: update existing escalation's voting strategy const escalation = await getEscalation(escalationId); if (!escalation) { await interaction.editReply({ - content: "Failed to re-escalate, couldn’t find escalation", + content: "Failed to re-escalate, couldn't find escalation", }); return; } - voteMessage = await interaction.channel?.messages.fetch( - escalation.vote_message_id, - ); + voteMessage = await channel.messages.fetch(escalation.vote_message_id); if (!voteMessage) { await interaction.editReply({ - content: "Failed to re-escalation: couldn't find vote message", + content: "Failed to re-escalate: couldn't find vote message", }); return; } - await voteMessage.edit(content); - } - // Now create escalation record with the correct message ID - await createEscalation({ - id: escalationId as `${string}-${string}-${string}-${string}-${string}`, - guildId, - threadId, - voteMessageId: voteMessage.id, - reportedUserId, - initiatorId: interaction.user.id, - quorum, - }); + // Get current votes to display + const votes = await getVotesForEscalation(escalationId); + const tally = tallyVotes(votes); + + // Recalculate scheduled_for based on current vote count + const newScheduledFor = calculateScheduledFor( + escalation.created_at, + tally.totalVotes, + ); - // Send notification - await interaction.editReply("Escalation started"); + // Create updated escalation object + const updatedEscalation: Escalation = { + ...escalation, + voting_strategy: votingStrategy, + scheduled_for: newScheduledFor, + }; + + await voteMessage.edit({ + content: buildVoteMessageContent( + modRoleId, + votingStrategy, + updatedEscalation, + tally, + ), + components: buildVoteButtons( + features, + votingStrategy, + escalation, + tally, + false, // Never in early resolution state when re-escalating to majority + ), + }); + + // Update the escalation's voting strategy + if (votingStrategy) { + await updateEscalationStrategy(escalationId, votingStrategy); + } + + await updateScheduledFor(escalationId, newScheduledFor); + + // Send notification + await interaction.editReply("Escalation upgraded to majority voting"); + } } catch (error) { log("error", "EscalationHandlers", "Error creating escalation vote", { error, diff --git a/app/commands/escalate/strings.test.ts b/app/commands/escalate/strings.test.ts index e800655..f9a54ca 100644 --- a/app/commands/escalate/strings.test.ts +++ b/app/commands/escalate/strings.test.ts @@ -1,5 +1,6 @@ import { tallyVotes, type VoteTally } from "#~/commands/escalate/voting"; import { resolutions } from "#~/helpers/modResponse"; +import type { Escalation } from "#~/models/escalationVotes.server"; import { buildConfirmedMessageContent, @@ -9,6 +10,27 @@ import { const emptyTally: VoteTally = tallyVotes([]); +// Helper to create mock escalation objects for testing +function createMockEscalation(overrides: Partial = {}): Escalation { + const createdAt = new Date("2024-01-01T12:00:00Z").toISOString(); + const scheduledFor = new Date("2024-01-02T12:00:00Z").toISOString(); // 24h later + return { + id: "test-escalation-id", + guild_id: "test-guild", + thread_id: "test-thread", + vote_message_id: "test-message", + reported_user_id: "123456789", + initiator_id: "987654321", + flags: JSON.stringify({ quorum: 3 }), + created_at: createdAt, + resolved_at: null, + resolution: null, + voting_strategy: null, + scheduled_for: scheduledFor, + ...overrides, + }; +} + describe("buildVotesListContent", () => { it("returns empty string for no votes", () => { const result = buildVotesListContent(emptyTally); @@ -47,20 +69,15 @@ describe("buildVotesListContent", () => { }); describe("buildVoteMessageContent", () => { - const reportedUserId = "123456789"; - const initiatorId = "987654321"; const modRoleId = "564738291"; - const createdAt = new Date("2024-01-01T12:00:00Z").toISOString(); it("shows vote count toward quorum", () => { + const escalation = createMockEscalation(); const result = buildVoteMessageContent( modRoleId, - - initiatorId, - reportedUserId, + "simple", + escalation, emptyTally, - 3, - createdAt, ); expect(result).toMatch(/0 vote.*quorum at 3/); @@ -68,82 +85,19 @@ describe("buildVoteMessageContent", () => { }); it("mentions the reported user", () => { + const escalation = createMockEscalation(); const result = buildVoteMessageContent( modRoleId, - - initiatorId, - reportedUserId, + "simple", + escalation, emptyTally, - 3, - createdAt, - ); - - expect(result).toContain(`<@${reportedUserId}>`); - }); - - it("shows auto-resolve time based on vote count", () => { - // 0 votes = 24h timeout - const result0 = buildVoteMessageContent( - modRoleId, - - initiatorId, - reportedUserId, - emptyTally, - 3, - createdAt, - ); - expect(result0).toContain("No votes yet"); - - // 1 vote = 16h timeout - const tally1 = tallyVotes([{ vote: resolutions.ban, voter_id: "u1" }]); - const result1 = buildVoteMessageContent( - modRoleId, - - initiatorId, - reportedUserId, - tally1, - 3, - createdAt, - ); - expect(result1).not.toContain("null"); - expect(result1).toContain("16h"); - - // 2 votes = 8h timeout - const tally2 = tallyVotes([ - { vote: resolutions.ban, voter_id: "u1" }, - { vote: resolutions.ban, voter_id: "u2" }, - ]); - const result2 = buildVoteMessageContent( - modRoleId, - - initiatorId, - reportedUserId, - tally2, - 3, - createdAt, ); - expect(result2).not.toContain("null"); - expect(result2).toContain("8h"); - // 2 votes = 8h timeout but tied - const tally3 = tallyVotes([ - { vote: resolutions.ban, voter_id: "u1" }, - { vote: resolutions.kick, voter_id: "u2" }, - ]); - const result3 = buildVoteMessageContent( - modRoleId, - - initiatorId, - reportedUserId, - tally3, - 3, - createdAt, - ); - expect(result3).not.toContain("null"); - expect(result3).toContain("8h"); + expect(result).toContain(`<@${escalation.reported_user_id}>`); }); it("shows quorum reached status when votes >= quorum", () => { + const escalation = createMockEscalation(); const tally = tallyVotes([ { vote: resolutions.ban, voter_id: "u1" }, { vote: resolutions.ban, voter_id: "u2" }, @@ -151,11 +105,9 @@ describe("buildVoteMessageContent", () => { ]); const result = buildVoteMessageContent( modRoleId, - initiatorId, - reportedUserId, + "simple", + escalation, tally, - 3, - createdAt, ); expect(result).toContain("Quorum reached"); @@ -163,34 +115,34 @@ describe("buildVoteMessageContent", () => { }); it("shows tied status when quorum reached but tied", () => { + const escalation = createMockEscalation(); + // Need 3+ votes for each option to reach quorum while tied const tally = tallyVotes([ { vote: resolutions.ban, voter_id: "u1" }, { vote: resolutions.ban, voter_id: "u2" }, - { vote: resolutions.kick, voter_id: "u3" }, + { vote: resolutions.ban, voter_id: "u3" }, { vote: resolutions.kick, voter_id: "u4" }, + { vote: resolutions.kick, voter_id: "u5" }, + { vote: resolutions.kick, voter_id: "u6" }, ]); const result = buildVoteMessageContent( modRoleId, - initiatorId, - reportedUserId, + "simple", + escalation, tally, - 3, - createdAt, ); - expect(result).toContain("Tied"); + expect(result).toContain("Tied between"); expect(result).toContain("tiebreaker"); }); it("includes Discord timestamp", () => { + const escalation = createMockEscalation(); const result = buildVoteMessageContent( modRoleId, - - initiatorId, - reportedUserId, + "simple", + escalation, emptyTally, - 3, - createdAt, ); expect(result).toMatch(//); @@ -198,20 +150,17 @@ describe("buildVoteMessageContent", () => { }); describe("buildConfirmedMessageContent", () => { - const reportedUserId = "123456789"; - const createdAt = new Date("2024-01-01T12:00:00Z").toISOString(); - it("shows the confirmed resolution", () => { + const escalation = createMockEscalation(); const tally = tallyVotes([ { vote: resolutions.ban, voter_id: "u1" }, { vote: resolutions.ban, voter_id: "u2" }, { vote: resolutions.ban, voter_id: "u3" }, ]); const result = buildConfirmedMessageContent( - reportedUserId, + escalation, resolutions.ban, tally, - createdAt, ); expect(result).toContain("Ban"); @@ -219,32 +168,32 @@ describe("buildConfirmedMessageContent", () => { }); it("mentions the reported user", () => { + const escalation = createMockEscalation(); const tally = tallyVotes([ { vote: resolutions.kick, voter_id: "u1" }, { vote: resolutions.kick, voter_id: "u2" }, { vote: resolutions.kick, voter_id: "u3" }, ]); const result = buildConfirmedMessageContent( - reportedUserId, + escalation, resolutions.kick, tally, - createdAt, ); - expect(result).toContain(`<@${reportedUserId}>`); + expect(result).toContain(`<@${escalation.reported_user_id}>`); }); it("shows execution timestamp", () => { + const escalation = createMockEscalation(); const tally = tallyVotes([ { vote: resolutions.track, voter_id: "u1" }, { vote: resolutions.track, voter_id: "u2" }, { vote: resolutions.track, voter_id: "u3" }, ]); const result = buildConfirmedMessageContent( - reportedUserId, + escalation, resolutions.track, tally, - createdAt, ); expect(result).toContain("Executes"); @@ -252,16 +201,16 @@ describe("buildConfirmedMessageContent", () => { }); it("includes vote record", () => { + const escalation = createMockEscalation(); const tally = tallyVotes([ { vote: resolutions.restrict, voter_id: "mod1" }, { vote: resolutions.restrict, voter_id: "mod2" }, { vote: resolutions.kick, voter_id: "mod3" }, ]); const result = buildConfirmedMessageContent( - reportedUserId, + escalation, resolutions.restrict, tally, - createdAt, ); expect(result).toContain("<@mod1>"); diff --git a/app/commands/escalate/strings.ts b/app/commands/escalate/strings.ts index 4fe8fdb..ddc3986 100644 --- a/app/commands/escalate/strings.ts +++ b/app/commands/escalate/strings.ts @@ -1,12 +1,14 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; -import { calculateTimeoutHours } from "#~/helpers/escalationVotes"; +import { parseFlags } from "#~/helpers/escalationVotes"; import type { Features } from "#~/helpers/featuresFlags.js"; import { humanReadableResolutions, resolutions, type Resolution, + type VotingStrategy, } from "#~/helpers/modResponse"; +import type { Escalation } from "#~/models/escalationVotes.server"; import type { VoteTally } from "./voting"; @@ -28,34 +30,55 @@ export function buildVotesListContent(tally: VoteTally) { */ export function buildVoteMessageContent( modRoleId: string, - initiatorId: string, - reportedUserId: string, + votingStrategy: VotingStrategy, + escalation: Escalation, tally: VoteTally, - quorum: number, - createdAt: string, ): string { - const createdTimestamp = Math.floor(new Date(createdAt).getTime() / 1000); - const timeoutHours = calculateTimeoutHours(tally.totalVotes); + const createdTimestamp = Math.floor( + new Date(escalation.created_at).getTime() / 1000, + ); + const scheduledFor = escalation.scheduled_for + ? Math.floor(new Date(escalation.scheduled_for).getTime() / 1000) + : null; + const flags = parseFlags(escalation.flags); + const quorum = flags.quorum; + const isMajority = votingStrategy === "majority"; let status: string; - if (tally.totalVotes >= quorum) { + if (isMajority) { + // Majority voting: always wait for timeout, plurality wins + if (tally.totalVotes === 0) { + status = scheduledFor + ? `Majority voting. Resolves with leading option.` + : `Majority voting. Waiting for votes.`; + } else if (tally.isTied) { + status = `Tied between: ${tally.tiedResolutions.map((r) => humanReadableResolutions[r]).join(", ")}. Tiebreak needed before timeout.`; + } else { + status = scheduledFor + ? `Leading: ${humanReadableResolutions[tally.leader!]} (${tally.leaderCount} votes). Resolves .` + : `Leading: ${humanReadableResolutions[tally.leader!]} (${tally.leaderCount} votes).`; + } + } else if (tally.leaderCount >= quorum) { + // Simple voting: quorum reached if (tally.isTied || !tally.leader) { status = `Tied between: ${tally.tiedResolutions.map((r) => humanReadableResolutions[r]).join(", ")}. Waiting for tiebreaker.`; } else { status = `Quorum reached. Leading: ${humanReadableResolutions[tally.leader]} (${tally.leaderCount} votes)`; } } else { + // Simple voting: quorum not reached status = `${tally.totalVotes} voter(s), quorum at ${quorum}.`; - if (tally.totalVotes > 0 && !tally.isTied) { - status += ` Auto-resolves with \`${tally.leader}\` in ${timeoutHours}h if no more votes.`; - } else if (tally.totalVotes > 0 && tally.isTied) { - status += ` Tiebreak needed in ${timeoutHours}h if no more votes are cast`; + if (tally.leaderCount > 0 && !tally.isTied && scheduledFor) { + status += ` Auto-resolves with \`${tally.leader}\` if no more votes.`; + } else if (tally.leaderCount > 0 && tally.isTied && scheduledFor) { + status += ` Tiebreak needed if no more votes are cast`; } } const votesList = buildVotesListContent(tally); + const strategyLabel = isMajority ? " (majority)" : ""; - return `<@${initiatorId}> called for a vote by <@&${modRoleId}> regarding user <@${reportedUserId}> + return `<@${escalation.initiator_id}> called for a vote${strategyLabel} by <@&${modRoleId}> regarding user <@${escalation.reported_user_id}> ${status} ${votesList || "_No votes yet_"}`; @@ -66,9 +89,10 @@ ${votesList || "_No votes yet_"}`; */ export function buildVoteButtons( enabledFeatures: Features[], - escalationId: string, + votingStrategy: VotingStrategy, + escalation: Escalation, tally: VoteTally, - quorumReached: boolean, + earlyResolutionTriggered: boolean, ): ActionRowBuilder[] { const resolutionList: Resolution[] = []; resolutionList.push(resolutions.track); @@ -84,9 +108,9 @@ export function buildVoteButtons( const voteCount = tally.byResolution.get(resolution)?.length ?? 0; const label = `${humanReadableResolutions[resolution]}${voteCount > 0 ? ` (${voteCount})` : ""}`; - // During a tie at quorum, disable non-tied options + // During a tie at quorum (simple voting), disable non-tied options const disabled = - quorumReached && + earlyResolutionTriggered && tally.isTied && !tally.tiedResolutions.includes(resolution); @@ -96,31 +120,51 @@ export function buildVoteButtons( // if (resolution === resolutions.warning) style = ButtonStyle.Primary; return new ButtonBuilder() - .setCustomId(`vote-${resolution}|${escalationId}`) + .setCustomId(`vote-${resolution}|${escalation.id}`) .setLabel(label) .setStyle(style) .setDisabled(disabled); }); - return [new ActionRowBuilder().addComponents(buttons)]; + const rows: ActionRowBuilder[] = [ + new ActionRowBuilder().addComponents(buttons), + ]; + + // Only show "Require majority vote" button if not already using majority strategy + if (votingStrategy !== "majority") { + rows.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId( + `escalate-escalate|${escalation.reported_user_id}|1|${escalation.id}`, + ) + .setLabel("Require majority vote") + .setStyle(ButtonStyle.Primary), + ), + ); + } + + return rows; } /** * Build message content for a confirmed resolution (quorum reached, awaiting execution). */ export function buildConfirmedMessageContent( - reportedUserId: string, + escalation: Escalation, resolution: Resolution, tally: VoteTally, - createdAt: string, ): string { - const timeoutHours = calculateTimeoutHours(tally.totalVotes); - const executeAt = - new Date(createdAt).getTime() + timeoutHours * 60 * 60 * 1000; - const executeTimestamp = Math.floor(executeAt / 1000); + const executeTimestamp = escalation.scheduled_for + ? Math.floor(new Date(escalation.scheduled_for).getTime() / 1000) + : null; + + const executesLine = executeTimestamp + ? `Executes ` + : "Executes soon"; - return `**${humanReadableResolutions[resolution]}** ✅ <@${reportedUserId}> -Executes + return `**${humanReadableResolutions[resolution]}** ✅ <@${escalation.reported_user_id}> +${executesLine} ${buildVotesListContent(tally)}`; } diff --git a/app/commands/escalate/voting.ts b/app/commands/escalate/voting.ts index e9db3e1..877bba7 100644 --- a/app/commands/escalate/voting.ts +++ b/app/commands/escalate/voting.ts @@ -1,4 +1,4 @@ -import type { Resolution } from "#~/helpers/modResponse"; +import type { Resolution, VotingStrategy } from "#~/helpers/modResponse"; import { log } from "#~/helpers/observability.js"; export interface VoteTally { @@ -60,3 +60,21 @@ export function tallyVotes(votes: VoteRecord[]): VoteTally { return output; } + +/** + * Check if early resolution should trigger based on voting strategy. + * - simple: triggers when any option hits quorum (e.g., 3 votes) + * - majority: never triggers early; must wait for timeout + */ +export function shouldTriggerEarlyResolution( + tally: VoteTally, + quorum: number, + strategy: VotingStrategy | null, +): boolean { + // Majority strategy never triggers early - must wait for timeout + if (strategy === "majority") { + return false; + } + // Simple strategy (or null/default): trigger when any option hits quorum + return tally.leaderCount >= quorum; +} diff --git a/app/db.d.ts b/app/db.d.ts index 8b52ffb..a70fae2 100644 --- a/app/db.d.ts +++ b/app/db.d.ts @@ -28,8 +28,10 @@ export interface Escalations { reported_user_id: string; resolution: string | null; resolved_at: string | null; + scheduled_for: string | null; thread_id: string; vote_message_id: string; + voting_strategy: string | null; } export interface Guilds { diff --git a/app/discord/escalationResolver.ts b/app/discord/escalationResolver.ts index da819f6..a070f1e 100644 --- a/app/discord/escalationResolver.ts +++ b/app/discord/escalationResolver.ts @@ -1,7 +1,14 @@ -import { type Client, type Guild, type ThreadChannel } from "discord.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ComponentType, + type Client, + type Guild, + type Message, + type ThreadChannel, +} from "discord.js"; import { tallyVotes } from "#~/commands/escalate/voting.js"; -import { parseFlags, shouldAutoResolve } from "#~/helpers/escalationVotes.js"; import { humanReadableResolutions, resolutions, @@ -11,11 +18,12 @@ import { log, trackPerformance } from "#~/helpers/observability"; import { scheduleTask } from "#~/helpers/schedule"; import { applyRestriction, ban, kick, timeout } from "#~/models/discord.server"; import { - getPendingEscalations, + getDueEscalations, getVotesForEscalation, resolveEscalation, type Escalation, } from "#~/models/escalationVotes.server"; +import { fetchSettings, SETTINGS } from "#~/models/guilds.server.ts"; export async function executeResolution( resolution: Resolution, @@ -93,6 +101,32 @@ export async function executeResolution( const ONE_MINUTE = 60 * 1000; +/** + * Get disabled versions of all button components from a message. + */ +function getDisabledButtons( + message: Message, +): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + + for (const row of message.components) { + if (!("components" in row)) continue; + + const buttons = row.components.filter( + (c) => c.type === ComponentType.Button, + ); + if (buttons.length === 0) continue; + + rows.push( + new ActionRowBuilder().addComponents( + buttons.map((btn) => ButtonBuilder.from(btn).setDisabled(true)), + ), + ); + } + + return rows; +} + /** * Execute a resolution action on a user via scheduled auto-resolution. */ @@ -110,32 +144,69 @@ async function executeScheduledResolution( log("info", "EscalationResolver", "Auto-resolving escalation", logBag); try { - const [guild, channel] = await Promise.all([ + const { modLog } = await fetchSettings(escalation.guild_id, [ + SETTINGS.modLog, + ]); + const [guild, channel, reportedUser] = await Promise.all([ client.guilds.fetch(escalation.guild_id), client.channels.fetch(escalation.thread_id) as Promise, + client.users.fetch(escalation.reported_user_id).catch(() => null), + ]); + const [reportedMember, vote] = await Promise.all([ + guild.members.fetch(escalation.reported_user_id).catch(() => null), + channel.messages.fetch(escalation.vote_message_id), ]); - const reportedMember = await guild.members - .fetch(escalation.reported_user_id) - .catch(() => null); - const vote = await channel.messages.fetch(escalation.vote_message_id); + const now = Math.floor(Date.now() / 1000); + const createdAt = Math.floor( + Number(new Date(escalation.created_at)) / 1000, + ); + const elapsedHours = Math.floor((now - createdAt) / 60 / 60); + + // Handle case where user left the server or deleted their account if (!reportedMember) { - log("debug", "EscalationResolve", "Reported member failed to load"); + const userLeft = reportedUser !== null; + const reason = userLeft + ? "User left the server" + : "User account no longer exists"; + + log("info", "EscalationResolver", "Resolving escalation - user gone", { + ...logBag, + reason, + userLeft, + }); + + // Mark as resolved with "track" since we can't take action + await resolveEscalation(escalation.id, resolutions.track); + await vote.edit({ components: getDisabledButtons(vote) }); + try { + const displayName = reportedUser?.username ?? "Unknown User"; + const notice = await vote.reply({ + content: `Resolved: **${humanReadableResolutions[resolutions.track]}** <@${escalation.reported_user_id}> (${displayName}) +-# ${reason}. Resolved , ${elapsedHours}hrs after escalation`, + }); + await notice.forward(modLog); + } catch (error) { + log("warn", "EscalationResolver", "Could not update vote message", { + ...logBag, + error, + }); + } return; } await executeResolution(resolution, escalation, guild); await resolveEscalation(escalation.id, resolution); + await vote.edit({ + components: getDisabledButtons(vote), + }); try { - // @ts-expect-error cuz nullcheck but ! is harder to search for - const resolvedAt = new Date(escalation.resolved_at); - const elapsed = - Number(resolvedAt) - Number(new Date(escalation.created_at)); - await vote.reply({ - content: `Escalation Resolved: **${humanReadableResolutions[resolution]}** on <@${escalation.reported_user_id}> (${reportedMember.displayName})\n-# _(Resolved ), ${Math.floor(elapsed / 1000 / 60 / 60)}hrs later_`, - components: [], + const notice = await vote.reply({ + content: `Resolved: **${humanReadableResolutions[resolution]}** <@${escalation.reported_user_id}> (${reportedMember.displayName}) +-# Resolved , ${elapsedHours}hrs after escalation`, }); + await notice.forward(modLog); } catch (error) { log("warn", "EscalationResolver", "Could not update vote message", { ...logBag, @@ -158,30 +229,26 @@ async function executeScheduledResolution( } /** - * Check all pending escalations and auto-resolve any that have timed out. + * Check all due escalations and auto-resolve them. + * Uses scheduled_for column to determine which escalations are ready. */ async function checkPendingEscalations(client: Client): Promise { await trackPerformance("checkPendingEscalations", async () => { - const pending = await getPendingEscalations(); + const due = await getDueEscalations(); - if (pending.length === 0) { + if (due.length === 0) { return; } - log("debug", "EscalationResolver", "Checking pending escalations", { - count: pending.length, + log("debug", "EscalationResolver", "Processing due escalations", { + count: due.length, }); - for (const escalation of pending) { + for (const escalation of due) { try { const votes = await getVotesForEscalation(escalation.id); const tally = tallyVotes(votes); - const flags = parseFlags(escalation.flags); - - // Check if timeout has elapsed - if (!shouldAutoResolve(escalation.created_at, tally.totalVotes)) { - continue; - } + const votingStrategy = escalation.voting_strategy; // Determine the resolution to take let resolution: Resolution; @@ -195,17 +262,12 @@ async function checkPendingEscalations(client: Client): Promise { log("warn", "EscalationResolver", "Auto-resolve skipped due to tie", { escalationId: escalation.id, tiedResolutions: tally.tiedResolutions, + votingStrategy, }); resolution = resolutions.track; } else if (tally.leader) { - // Clear leader - const quorumReached = tally.leaderCount >= flags.quorum; - if (quorumReached) { - resolution = tally.leader; - } else { - // Not enough votes for quorum, take leading vote anyway on timeout - resolution = tally.leader; - } + // Clear leader - take leading vote on timeout (works for both simple and majority strategies) + resolution = tally.leader; } else { // Shouldn't happen, but default to track resolution = resolutions.track; diff --git a/app/helpers/escalationVotes.test.ts b/app/helpers/escalationVotes.test.ts index 855a33d..0cb7621 100644 --- a/app/helpers/escalationVotes.test.ts +++ b/app/helpers/escalationVotes.test.ts @@ -5,22 +5,14 @@ import { } from "./escalationVotes"; describe("calculateTimeoutHours", () => { - it("returns 24 hours with 0 votes", () => { - expect(calculateTimeoutHours(0)).toBe(24); - }); - - it("returns 16 hours with 1 vote", () => { - expect(calculateTimeoutHours(1)).toBe(16); - }); - - it("returns 8 hours with 2 votes", () => { - expect(calculateTimeoutHours(2)).toBe(8); - }); - - it("returns 0 hours with 3+ votes (quorum)", () => { - expect(calculateTimeoutHours(3)).toBe(0); - expect(calculateTimeoutHours(4)).toBe(0); + // Formula: max(0, 36 - 4 * (voteCount - 1)) + it("returns the expected number of hours based on votes", () => { + expect(calculateTimeoutHours(0)).toBe(36); + expect(calculateTimeoutHours(1)).toBe(32); + expect(calculateTimeoutHours(2)).toBe(28); + expect(calculateTimeoutHours(3)).toBe(24); expect(calculateTimeoutHours(10)).toBe(0); + expect(calculateTimeoutHours(11)).toBe(0); }); it("never returns negative", () => { @@ -29,43 +21,22 @@ describe("calculateTimeoutHours", () => { }); describe("shouldAutoResolve", () => { - it("resolves immediately with 3+ votes (0 hour timeout)", () => { - const now = new Date().toISOString(); - expect(shouldAutoResolve(now, 3)).toBe(true); - }); + // Formula: timeout = max(0, 36 - 4 * (voteCount - 1)) + // 0 votes = 40h, 1 vote = 36h, 2 votes = 32h, 3 votes = 28h it("does not resolve immediately with 0 votes", () => { const now = new Date().toISOString(); expect(shouldAutoResolve(now, 0)).toBe(false); }); - it("resolves after 24 hours with 0 votes", () => { - const over24hAgo = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); - expect(shouldAutoResolve(over24hAgo, 0)).toBe(true); - }); - - it("does not resolve at 23 hours with 0 votes", () => { - const under24hAgo = new Date( - Date.now() - 23 * 60 * 60 * 1000, - ).toISOString(); - expect(shouldAutoResolve(under24hAgo, 0)).toBe(false); - }); - - it("resolves after 16 hours with 1 vote", () => { - const over16hAgo = new Date(Date.now() - 17 * 60 * 60 * 1000).toISOString(); - expect(shouldAutoResolve(over16hAgo, 1)).toBe(true); - }); - - it("does not resolve at 15 hours with 1 vote", () => { - const under16hAgo = new Date( - Date.now() - 15 * 60 * 60 * 1000, - ).toISOString(); - expect(shouldAutoResolve(under16hAgo, 1)).toBe(false); + it("resolves after a long time with 0 votes", () => { + const aLongTime = new Date(Date.now() - 60 * 60 * 60 * 1000).toISOString(); + expect(shouldAutoResolve(aLongTime, 0)).toBe(true); }); - it("resolves after 8 hours with 2 votes", () => { - const over8hAgo = new Date(Date.now() - 9 * 60 * 60 * 1000).toISOString(); - expect(shouldAutoResolve(over8hAgo, 2)).toBe(true); + it("does not resolve early with 0 votes", () => { + const notLong = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + expect(shouldAutoResolve(notLong, 0)).toBe(false); }); }); diff --git a/app/helpers/escalationVotes.ts b/app/helpers/escalationVotes.ts index b4e75ef..a2d3432 100644 --- a/app/helpers/escalationVotes.ts +++ b/app/helpers/escalationVotes.ts @@ -11,11 +11,11 @@ export function parseFlags(flagsJson: string): EscalationFlags { } /** - * Calculate hours until auto-resolution based on vote count. - * Formula: 24 - (8 * voteCount), minimum 0 + * Calculate hours until auto-resolution based on vote count. The goal is to + * provide enough time for all mods to weigh in. */ export function calculateTimeoutHours(voteCount: number): number { - return Math.max(0, 24 - 8 * voteCount); + return Math.max(0, 36 - 4 * voteCount); } /** diff --git a/app/helpers/modResponse.ts b/app/helpers/modResponse.ts index b79a70f..fc54d78 100644 --- a/app/helpers/modResponse.ts +++ b/app/helpers/modResponse.ts @@ -15,3 +15,10 @@ export const humanReadableResolutions = { [resolutions.ban]: "Ban", } as const; export type Resolution = (typeof resolutions)[keyof typeof resolutions]; + +export const votingStrategies = { + simple: "simple", + majority: "majority", +} as const; +export type VotingStrategy = + (typeof votingStrategies)[keyof typeof votingStrategies]; diff --git a/app/models/escalationVotes.server.ts b/app/models/escalationVotes.server.ts index 68b8741..f38625a 100644 --- a/app/models/escalationVotes.server.ts +++ b/app/models/escalationVotes.server.ts @@ -1,46 +1,43 @@ -import type { Selectable } from "kysely"; +import type { Insertable, Selectable } from "kysely"; import db, { type DB } from "#~/db.server"; -import type { EscalationFlags } from "#~/helpers/escalationVotes.js"; -import type { Resolution } from "#~/helpers/modResponse"; +import { calculateTimeoutHours } from "#~/helpers/escalationVotes.js"; +import type { Resolution, VotingStrategy } from "#~/helpers/modResponse"; import { log, trackPerformance } from "#~/helpers/observability"; +/** + * Calculate the scheduled resolution time based on creation time and vote count. + */ +export function calculateScheduledFor( + createdAt: string, + voteCount: number, +): string { + const timeoutHours = calculateTimeoutHours(voteCount); + const scheduledFor = new Date( + new Date(createdAt).getTime() + timeoutHours * 60 * 60 * 1000, + ); + return scheduledFor.toISOString(); +} + export type Escalation = Selectable; export type EscalationRecord = Selectable; +type EscalationInsert = Insertable; -export async function createEscalation(data: { - id: `${string}-${string}-${string}-${string}-${string}`; - guildId: Escalation["guild_id"]; - threadId: Escalation["thread_id"]; - voteMessageId: Escalation["vote_message_id"]; - reportedUserId: Escalation["reported_user_id"]; - initiatorId: Escalation["initiator_id"]; - quorum: number; -}): Promise { +export async function createEscalation( + data: EscalationInsert, +): Promise { return trackPerformance("createEscalation", async () => { - const id = data.id; - const flags: EscalationFlags = { quorum: data.quorum }; - - await db - .insertInto("escalations") - .values({ - id, - guild_id: data.guildId, - thread_id: data.threadId, - vote_message_id: data.voteMessageId, - reported_user_id: data.reportedUserId, - initiator_id: data.initiatorId, - flags: JSON.stringify(flags), - }) - .execute(); + const createdAt = new Date().toISOString(); + const newEscalation = { + ...data, + // Initial scheduled_for is 36 hours from creation (0 votes) + scheduled_for: calculateScheduledFor(createdAt, 0), + }; - log("info", "EscalationVotes", "Created escalation", { - id, - guildId: data.guildId, - reportedUserId: data.reportedUserId, - }); + await db.insertInto("escalations").values(newEscalation).execute(); - return id; + log("info", "EscalationVotes", "Created escalation", data); + return newEscalation; }); } @@ -134,3 +131,52 @@ export async function resolveEscalation(id: string, resolution: Resolution) { log("info", "EscalationVotes", "Resolved escalation", { id, resolution }); }); } + +export async function updateEscalationStrategy( + id: string, + votingStrategy: VotingStrategy, +) { + return trackPerformance("updateEscalationStrategy", async () => { + await db + .updateTable("escalations") + .set({ voting_strategy: votingStrategy }) + .where("id", "=", id) + .execute(); + + log("info", "EscalationVotes", "Updated escalation strategy", { + id, + votingStrategy, + }); + }); +} + +export async function updateScheduledFor( + id: string, + scheduledFor: string, +): Promise { + return trackPerformance("updateScheduledFor", async () => { + await db + .updateTable("escalations") + .set({ scheduled_for: scheduledFor }) + .where("id", "=", id) + .execute(); + + log("info", "EscalationVotes", "Updated escalation scheduled_for", { + id, + scheduledFor, + }); + }); +} + +export async function getDueEscalations() { + return trackPerformance("getDueEscalations", async () => { + const escalations = await db + .selectFrom("escalations") + .selectAll() + .where("resolved_at", "is", null) + .where("scheduled_for", "<=", new Date().toISOString()) + .execute(); + + return escalations; + }); +} diff --git a/migrations/20251209140659_add_voting_strategy.ts b/migrations/20251209140659_add_voting_strategy.ts new file mode 100644 index 0000000..f107919 --- /dev/null +++ b/migrations/20251209140659_add_voting_strategy.ts @@ -0,0 +1,15 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("escalations") + .addColumn("voting_strategy", "text") + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("escalations") + .dropColumn("voting_strategy") + .execute(); +} diff --git a/migrations/20251217145416_add_scheduled_for.ts b/migrations/20251217145416_add_scheduled_for.ts new file mode 100644 index 0000000..b3f6428 --- /dev/null +++ b/migrations/20251217145416_add_scheduled_for.ts @@ -0,0 +1,44 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + // 1. Add the column + await db.schema + .alterTable("escalations") + .addColumn("scheduled_for", "text") + .execute(); + + // 2. Backfill pending escalations based on their current vote count + const pending = await db + .selectFrom("escalations") + .select(["id", "created_at"]) + .where("resolved_at", "is", null) + .execute(); + + for (const escalation of pending) { + // Count votes for this escalation + const voteResult = await db + .selectFrom("escalation_records") + .select(db.fn.count("id").as("count")) + .where("escalation_id", "=", escalation.id) + .executeTakeFirst(); + + const voteCount = Number(voteResult?.count ?? 0); + const timeoutHours = Math.max(0, 36 - 4 * voteCount); + const scheduledFor = new Date( + new Date(escalation.created_at).getTime() + timeoutHours * 60 * 60 * 1000, + ).toISOString(); + + await db + .updateTable("escalations") + .set({ scheduled_for: scheduledFor }) + .where("id", "=", escalation.id) + .execute(); + } +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("escalations") + .dropColumn("scheduled_for") + .execute(); +} diff --git a/notes/2025-12-09_1_voting-strategy-implementation.md b/notes/2025-12-09_1_voting-strategy-implementation.md new file mode 100644 index 0000000..cb81101 --- /dev/null +++ b/notes/2025-12-09_1_voting-strategy-implementation.md @@ -0,0 +1,56 @@ +# Voting Strategy Implementation + +## Summary + +Added support for two voting strategies: + +- **simple** (default/null): Early resolution when any option hits quorum (3 votes) +- **majority**: No early resolution; voting stays open until timeout, then plurality wins + +## Key Changes + +### Database + +- Added `voting_strategy` nullable column to `escalations` table +- Migration: `migrations/20251209140659_add_voting_strategy.ts` + +### modResponse.ts + +- Added `votingStrategies` constant object and `VotingStrategy` type + +### escalationVotes.server.ts + +- `createEscalation` now accepts optional `votingStrategy` parameter +- Added `updateEscalationStrategy(id, strategy)` for re-escalation + +### voting.ts + +- Added `shouldTriggerEarlyResolution(tally, quorum, strategy)`: + - Returns `false` for majority strategy (never triggers early) + - Returns `leaderCount >= quorum` for simple strategy + +### handlers.ts + +- Vote handler uses `shouldTriggerEarlyResolution` instead of direct quorum check +- Escalate handler: + - Level 0: creates with `votingStrategy: null` (simple) + - Level 1+: updates existing escalation to `majority` strategy +- Passes voting strategy to string builders + +### strings.ts + +- `buildVoteMessageContent` shows strategy-specific status messages +- `buildVoteButtons` hides "Require majority vote" button if already using majority + +### escalationResolver.ts + +- Removed unused `parseFlags` import (quorum check was redundant) +- Both strategies resolve identically on timeout: plurality wins + +## Behavior Summary + +| Action | Simple Strategy | Majority Strategy | +| ------------------------- | -------------------------------------- | ------------------- | +| 3 votes for same option | Shows "confirmed", schedules execution | Continues voting | +| Timeout reached | Leading option wins | Leading option wins | +| "Require majority" button | Visible | Hidden | diff --git a/notes/2025-12-09_2_voting-test-challenges.md b/notes/2025-12-09_2_voting-test-challenges.md new file mode 100644 index 0000000..82c0cce --- /dev/null +++ b/notes/2025-12-09_2_voting-test-challenges.md @@ -0,0 +1,129 @@ +# Escalation Feature Testing Challenges + +The voting strategy feature presents significant testing challenges due to its deep integration with Discord's API, time-dependent behavior, +and complex multi-actor workflows. This report analyzes these challenges and proposes practical solutions for effective testing. + +## Challenges + +### Discord API Dependency + +Challenge 1: + +The EscalationHandlers interact heavily with Discord.js types (MessageComponentInteraction, ButtonBuilder, etc.): + +- Vote handler requires interaction.guildId, interaction.customId, interaction.user.id +- Calls interaction.reply(), interaction.update(), interaction.deferReply() +- Fetches guild settings, mod roles, and member permissions +- Sends/edits messages in Discord channels +- Mocking all required properties and methods is verbose and brittle + +Current State + +- Existing e2e tests (tests/e2e/mocks/discord.ts) only mock REST API endpoints for web flows +- No infrastructure for mocking Discord.js gateway/interaction objects +- No unit tests for handlers—only for pure functions (tallyVotes, buildVoteMessageContent) + +### Time-Dependent Behavior + +Challenge 2: + +- The auto-resolution system depends on elapsed time +- No mechanism to inject time or fast-forward in tests +- The escalationResolver scheduler runs every 15 minutes in production + +### Multi-Actor Workflows + +Challenge 3: + +Real voting scenarios involve multiple moderators interacting sequentially: + +1. Mod A escalates → creates vote +2. Mod B votes "ban" +3. Mod C votes "kick" +4. Mod D votes "ban" → triggers quorum (simple strategy) +5. OR: Mod E clicks "Require majority vote" → changes strategy, cancels scheduled resolution + +### Database + External Service Coordination + +Challenge 4: + +A single vote operation: + +1. Reads from escalations table (get escalation) +2. Writes to escalation_records table (record vote) +3. Reads escalation_records again (tally votes) +4. Reads guild settings from guilds table +5. Calls Discord API to update message + +- Need real database for integration tests (existing DbFixture helps) +- Discord API calls must be mocked/stubbed + +## Proposed Solutions + +([see #211 for more information on solutions](https://github.com/reactiflux/mod-bot/issues/211#issuecomment-3635336824)) + +### Extract Pure Business Logic + +Solution 1: + +Separate decision logic from Discord I/O. + +Benefits: + +- Test all voting logic permutations without Discord mocks +- 100+ test cases possible in milliseconds +- Clear separation of concerns + +Files to create: + +- `app/commands/escalate/voting-logic.ts` - pure functions +- `app/commands/escalate/voting-logic.test.ts` - comprehensive tests + +### Time Abstraction + +Solution 2: + +Approach: Inject time provider for testability + +Benefits: + +- Test timeout logic without waiting +- Verify edge cases (exactly at timeout, 1ms before, etc.) + +--- + +Solution 4: Integration Test Fixtures + +Approach: Extend DbFixture for escalation testing + +// tests/e2e/fixtures/escalation.ts +export class EscalationFixture { +constructor(private db: DbFixture) {} + +async createEscalation(options: { +guildId: string; +votingStrategy?: VotingStrategy; +votes?: Array<{ resolution: Resolution; voterId: string }>; +createdAt?: string; // Allow backdating for timeout tests +}): Promise { +// Insert escalation and votes +} + +async addVote(escalationId: string, vote: Resolution, voterId: string) { +// Insert vote record +} + +async getEscalationState(id: string): Promise<{ +escalation: Escalation; +votes: VoteRecord[]; +tally: VoteTally; +}> { +// Retrieve current state for assertions +} +} + +Benefits: + +- Reusable test setup +- Database-backed integration tests +- Verify real persistence behavior diff --git a/notes/2025-12-15_3_invalid-session-cookie-clearing.md b/notes/2025-12-15_3_invalid-session-cookie-clearing.md index 6608926..9bf755c 100644 --- a/notes/2025-12-15_3_invalid-session-cookie-clearing.md +++ b/notes/2025-12-15_3_invalid-session-cookie-clearing.md @@ -11,10 +11,12 @@ When session cookies were invalid (expired database sessions, corrupted cookies, ## Solution Modified `getUserId()` in `session.server.ts` to detect when: + 1. Session cookies are present in the request (`__session` or `__client-session`) 2. But no valid userId was found in the session When this condition is detected, we call `logout(request)` which: + - Destroys both session cookies (sets them to expire) - Redirects to `/` @@ -32,4 +34,4 @@ if (!userId) { throw await logout(request); } } -``` \ No newline at end of file +``` diff --git a/notes/2025-12-17_1_escalation-scheduling-refactor.md b/notes/2025-12-17_1_escalation-scheduling-refactor.md new file mode 100644 index 0000000..05ab39a --- /dev/null +++ b/notes/2025-12-17_1_escalation-scheduling-refactor.md @@ -0,0 +1,52 @@ +# Escalation Scheduling Refactor: `scheduled_for` Column + +## Problem + +Previous escalation timeout logic computed resolution time dynamically on every 15-minute poll: + +1. Query all pending escalations (`resolved_at IS NULL`) +2. For each: fetch votes, tally, calculate `36 - 4 * voteCount` hours from `created_at` +3. Compare elapsed time to computed timeout + +Inefficient—vote count determines timeout, but we recalculated on every poll instead of when votes changed. + +## Solution + +Added `scheduled_for` column that stores the computed resolution timestamp. Updated whenever votes change. Poll query becomes: + +```sql +SELECT * FROM escalations +WHERE resolved_at IS NULL +AND scheduled_for <= datetime('now') +``` + +## Changes + +### Migration (`20251217145416_add_scheduled_for.ts`) + +- Added `scheduled_for` text column +- Backfills existing pending escalations based on current vote count + +### Model (`escalationVotes.server.ts`) + +- `calculateScheduledFor(createdAt, voteCount)` - computes scheduled time +- `updateScheduledFor(id, scheduledFor)` - persists new scheduled time +- `getDueEscalations()` - queries escalations past their scheduled time +- `createEscalation()` - now sets initial `scheduled_for` (36h from creation) + +### Handlers (`handlers.ts`) + +- Vote handler: updates `scheduled_for` after recording vote +- Re-escalation handler: updates `scheduled_for` when upgrading to majority voting + +### Resolver (`escalationResolver.ts`) + +- Simplified to use `getDueEscalations()` instead of `getPendingEscalations()` +- Removed `shouldAutoResolve()` check—query already filters for due escalations + +## Behavior + +- New escalation: `scheduled_for = created_at + 36h` +- Each vote: `scheduled_for = created_at + (36 - 4 * voteCount)h` +- Vote removal: timeout increases (recalculated) +- Poll: only processes escalations where `scheduled_for` has passed