Log custom votes to database. (#42650)

* Log custom votes to database.

* Add navigation property from round entity
This commit is contained in:
Pieter-Jan Briers 2026-06-11 23:46:18 +02:00 • committed by GitHub
parent 0cd25ac653
commit 9da1cff855
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 5044 additions and 3 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class CustomVoteLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "custom_vote_log",
columns: table => new
{
custom_vote_log_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
round_id = table.Column<int>(type: "integer", nullable: false),
time_created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
title = table.Column<string>(type: "text", nullable: false),
initiator_id = table.Column<Guid>(type: "uuid", nullable: true),
state = table.Column<byte>(type: "smallint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_custom_vote_log", x => x.custom_vote_log_id);
table.ForeignKey(
name: "FK_custom_vote_log_player_initiator_id1",
column: x => x.initiator_id,
principalTable: "player",
principalColumn: "user_id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_custom_vote_log_round_round_id",
column: x => x.round_id,
principalTable: "round",
principalColumn: "round_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "custom_vote_log_option",
columns: table => new
{
vote_id = table.Column<int>(type: "integer", nullable: false),
option_idx = table.Column<short>(type: "smallint", nullable: false),
text = table.Column<string>(type: "text", nullable: false),
vote_count = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_custom_vote_log_option", x => new { x.vote_id, x.option_idx });
table.ForeignKey(
name: "FK_custom_vote_log_option_custom_vote_log_vote_id",
column: x => x.vote_id,
principalTable: "custom_vote_log",
principalColumn: "custom_vote_log_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_custom_vote_log_initiator_id",
table: "custom_vote_log",
column: "initiator_id");
migrationBuilder.CreateIndex(
name: "IX_custom_vote_log_round_id",
table: "custom_vote_log",
column: "round_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "custom_vote_log_option");
migrationBuilder.DropTable(
name: "custom_vote_log");
}
}
}

View file

@ -849,6 +849,72 @@ namespace Content.Server.Database.Migrations.Postgres
});
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("custom_vote_log_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Guid?>("InitiatorId")
.HasColumnType("uuid")
.HasColumnName("initiator_id");
b.Property<int>("RoundId")
.HasColumnType("integer")
.HasColumnName("round_id");
b.Property<byte>("State")
.HasColumnType("smallint")
.HasColumnName("state");
b.Property<DateTime>("TimeCreated")
.HasColumnType("timestamp with time zone")
.HasColumnName("time_created");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.HasKey("Id")
.HasName("PK_custom_vote_log");
b.HasIndex("InitiatorId");
b.HasIndex("RoundId")
.HasDatabaseName("IX_custom_vote_log_round_id");
b.ToTable("custom_vote_log", (string)null);
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLogOption", b =>
{
b.Property<int>("VoteId")
.HasColumnType("integer")
.HasColumnName("vote_id");
b.Property<short>("OptionIdx")
.HasColumnType("smallint")
.HasColumnName("option_idx");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text");
b.Property<int>("VoteCount")
.HasColumnType("integer")
.HasColumnName("vote_count");
b.HasKey("VoteId", "OptionIdx")
.HasName("PK_custom_vote_log_option");
b.ToTable("custom_vote_log_option", (string)null);
});
modelBuilder.Entity("Content.Server.Database.IPIntelCache", b =>
{
b.Property<int>("Id")
@ -1831,6 +1897,39 @@ namespace Content.Server.Database.Migrations.Postgres
b.Navigation("Server");
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLog", b =>
{
b.HasOne("Content.Server.Database.Player", "Initiator")
.WithMany()
.HasForeignKey("InitiatorId")
.HasPrincipalKey("UserId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("FK_custom_vote_log_player_initiator_id1");
b.HasOne("Content.Server.Database.Round", "Round")
.WithMany("CustomVoteLogs")
.HasForeignKey("RoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_custom_vote_log_round_round_id");
b.Navigation("Initiator");
b.Navigation("Round");
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLogOption", b =>
{
b.HasOne("Content.Server.Database.CustomVoteLog", "Vote")
.WithMany("Options")
.HasForeignKey("VoteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_custom_vote_log_option_custom_vote_log_vote_id");
b.Navigation("Vote");
});
modelBuilder.Entity("Content.Server.Database.Job", b =>
{
b.HasOne("Content.Server.Database.Profile", "Profile")
@ -2048,6 +2147,11 @@ namespace Content.Server.Database.Migrations.Postgres
b.Navigation("BanHits");
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLog", b =>
{
b.Navigation("Options");
});
modelBuilder.Entity("Content.Server.Database.Player", b =>
{
b.Navigation("AdminLogs");
@ -2112,6 +2216,8 @@ namespace Content.Server.Database.Migrations.Postgres
modelBuilder.Entity("Content.Server.Database.Round", b =>
{
b.Navigation("AdminLogs");
b.Navigation("CustomVoteLogs");
});
modelBuilder.Entity("Content.Server.Database.Server", b =>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,84 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
/// <inheritdoc />
public partial class CustomVoteLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "custom_vote_log",
columns: table => new
{
custom_vote_log_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
round_id = table.Column<int>(type: "INTEGER", nullable: false),
time_created = table.Column<DateTime>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
initiator_id = table.Column<Guid>(type: "TEXT", nullable: true),
state = table.Column<byte>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_custom_vote_log", x => x.custom_vote_log_id);
table.ForeignKey(
name: "FK_custom_vote_log_player_initiator_id1",
column: x => x.initiator_id,
principalTable: "player",
principalColumn: "user_id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_custom_vote_log_round_round_id",
column: x => x.round_id,
principalTable: "round",
principalColumn: "round_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "custom_vote_log_option",
columns: table => new
{
vote_id = table.Column<int>(type: "INTEGER", nullable: false),
option_idx = table.Column<short>(type: "INTEGER", nullable: false),
text = table.Column<string>(type: "TEXT", nullable: false),
vote_count = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_custom_vote_log_option", x => new { x.vote_id, x.option_idx });
table.ForeignKey(
name: "FK_custom_vote_log_option_custom_vote_log_vote_id",
column: x => x.vote_id,
principalTable: "custom_vote_log",
principalColumn: "custom_vote_log_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_custom_vote_log_initiator_id",
table: "custom_vote_log",
column: "initiator_id");
migrationBuilder.CreateIndex(
name: "IX_custom_vote_log_round_id",
table: "custom_vote_log",
column: "round_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "custom_vote_log_option");
migrationBuilder.DropTable(
name: "custom_vote_log");
}
}
}

View file

@ -798,6 +798,70 @@ namespace Content.Server.Database.Migrations.Sqlite
b.ToTable("connection_log", (string)null);
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("custom_vote_log_id");
b.Property<Guid?>("InitiatorId")
.HasColumnType("TEXT")
.HasColumnName("initiator_id");
b.Property<int>("RoundId")
.HasColumnType("INTEGER")
.HasColumnName("round_id");
b.Property<byte>("State")
.HasColumnType("INTEGER")
.HasColumnName("state");
b.Property<DateTime>("TimeCreated")
.HasColumnType("TEXT")
.HasColumnName("time_created");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id")
.HasName("PK_custom_vote_log");
b.HasIndex("InitiatorId");
b.HasIndex("RoundId")
.HasDatabaseName("IX_custom_vote_log_round_id");
b.ToTable("custom_vote_log", (string)null);
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLogOption", b =>
{
b.Property<int>("VoteId")
.HasColumnType("INTEGER")
.HasColumnName("vote_id");
b.Property<short>("OptionIdx")
.HasColumnType("INTEGER")
.HasColumnName("option_idx");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.Property<int>("VoteCount")
.HasColumnType("INTEGER")
.HasColumnName("vote_count");
b.HasKey("VoteId", "OptionIdx")
.HasName("PK_custom_vote_log_option");
b.ToTable("custom_vote_log_option", (string)null);
});
modelBuilder.Entity("Content.Server.Database.IPIntelCache", b =>
{
b.Property<int>("Id")
@ -1750,6 +1814,39 @@ namespace Content.Server.Database.Migrations.Sqlite
b.Navigation("Server");
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLog", b =>
{
b.HasOne("Content.Server.Database.Player", "Initiator")
.WithMany()
.HasForeignKey("InitiatorId")
.HasPrincipalKey("UserId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("FK_custom_vote_log_player_initiator_id1");
b.HasOne("Content.Server.Database.Round", "Round")
.WithMany("CustomVoteLogs")
.HasForeignKey("RoundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_custom_vote_log_round_round_id");
b.Navigation("Initiator");
b.Navigation("Round");
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLogOption", b =>
{
b.HasOne("Content.Server.Database.CustomVoteLog", "Vote")
.WithMany("Options")
.HasForeignKey("VoteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_custom_vote_log_option_custom_vote_log_vote_id");
b.Navigation("Vote");
});
modelBuilder.Entity("Content.Server.Database.Job", b =>
{
b.HasOne("Content.Server.Database.Profile", "Profile")
@ -1967,6 +2064,11 @@ namespace Content.Server.Database.Migrations.Sqlite
b.Navigation("BanHits");
});
modelBuilder.Entity("Content.Server.Database.CustomVoteLog", b =>
{
b.Navigation("Options");
});
modelBuilder.Entity("Content.Server.Database.Player", b =>
{
b.Navigation("AdminLogs");
@ -2031,6 +2133,8 @@ namespace Content.Server.Database.Migrations.Sqlite
modelBuilder.Entity("Content.Server.Database.Round", b =>
{
b.Navigation("AdminLogs");
b.Navigation("CustomVoteLogs");
});
modelBuilder.Entity("Content.Server.Database.Server", b =>

View file

@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace Content.Server.Database;
//
// Contains model definitions primarily related to custom vote logging.
//
internal static class ModelCustomVoteLog
{
public static void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CustomVoteLog>()
.HasOne(cvl => cvl.Initiator)
.WithMany()
.HasForeignKey(cvl => cvl.InitiatorId)
.HasPrincipalKey(p => p.UserId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
modelBuilder.Entity<CustomVoteLogOption>()
.HasKey(cvl => new { cvl.VoteId, cvl.OptionIdx });
}
}
/// <summary>
/// A single admin-initiated custom vote logged in the database.
/// </summary>
public sealed class CustomVoteLog
{
public int Id { get; set; }
/// <summary>
/// The round ID the vote was made in.
/// </summary>
public int RoundId { get; set; }
/// <summary>
/// The time the vote was created at.
/// </summary>
public DateTime TimeCreated { get; set; }
/// <summary>
/// The player-facing title for the vote.
/// </summary>
public required string Title { get; set; }
/// <summary>
/// The user ID of the admin that created the vote.
/// </summary>
public Guid? InitiatorId { get; set; }
/// <summary>
/// State of the vote.
/// </summary>
public CustomVoteState State { get; set; }
/// <summary>
/// The playing-facing options the vote has.
/// </summary>
public List<CustomVoteLogOption>? Options { get; set; }
/// <summary>
/// The round the vote was made in.
/// </summary>
public Round? Round { get; set; }
/// <summary>
/// The admin that created the vote.
/// </summary>
public Player? Initiator { get; set; }
}
/// <summary>
/// An option for a logged <see cref="CustomVoteLog"/>.
/// </summary>
public sealed class CustomVoteLogOption
{
/// <summary>
/// The vote ID this option is part of.
/// </summary>
public int VoteId { get; set; }
/// <summary>
/// The index of the option in the option list.
/// </summary>
public short OptionIdx { get; set; }
/// <summary>
/// The player-facing text for this option.
/// </summary>
public required string Text { get; set; }
/// <summary>
/// How many players voted for this option.
/// </summary>
/// <remarks>
/// This field is only populated for votes with state <see cref="CustomVoteState.Finished"/>.
/// </remarks>
public int VoteCount { get; set; }
/// <summary>
/// The vote this option is part of.
/// </summary>
public CustomVoteLog? Vote { get; set; }
}
/// <summary>
/// Possible states for <see cref="CustomVoteLog"/> entries.
/// </summary>
public enum CustomVoteState : byte
{
/// <summary>
/// The vote is still active (likely was just made).
/// </summary>
Active,
/// <summary>
/// The vote finished and vote counts were written to database.
/// </summary>
Finished,
/// <summary>
/// The vote was canceled, no vote counts have been written to database.
/// </summary>
Cancelled,
}

View file

@ -49,6 +49,8 @@ namespace Content.Server.Database
public DbSet<RoleWhitelist> RoleWhitelists { get; set; } = null!;
public DbSet<BanTemplate> BanTemplate { get; set; } = null!;
public DbSet<IPIntelCache> IPIntelCache { get; set; } = null!;
public DbSet<CustomVoteLog> CustomVoteLog { get; set; } = null!;
public DbSet<CustomVoteLogOption> CustomVoteLogOption { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@ -296,6 +298,7 @@ namespace Content.Server.Database
.HasDefaultValue(HwidType.Legacy);
ModelBan.OnModelCreating(modelBuilder);
ModelCustomVoteLog.OnModelCreating(modelBuilder);
}
public virtual IQueryable<AdminLog> SearchLogs(IQueryable<AdminLog> query, string searchText)
@ -595,6 +598,8 @@ namespace Content.Server.Database
public List<AdminLog> AdminLogs { get; set; } = default!;
public List<CustomVoteLog> CustomVoteLogs { get; set; } = default!;
[ForeignKey("Server")] public int ServerId { get; set; }
public Server Server { get; set; } = default!;
}

View file

@ -1632,6 +1632,68 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
#endregion
#region Custom vote logging
public async Task<int> CustomVoteLogAdd(
string title,
int roundId,
Guid? initiator,
ImmutableArray<string> options)
{
await using var db = await GetDb();
var log = new CustomVoteLog
{
Title = title,
RoundId = roundId,
InitiatorId = initiator,
State = CustomVoteState.Active,
TimeCreated = DateTime.UtcNow,
Options = options.Select((o, i) => new CustomVoteLogOption
{
Text = o,
OptionIdx = (short)i,
VoteCount = 0,
})
.ToList(),
};
db.DbContext.CustomVoteLog.Add(log);
await db.DbContext.SaveChangesAsync();
return log.Id;
}
public async Task CustomVoteLogFinish(int voteId, ImmutableArray<int> voteCounts)
{
await using var db = await GetDb();
var log = await db.DbContext.CustomVoteLog
.Include(cvl => cvl.Options)
.SingleAsync(v => v.Id == voteId);
log.State = CustomVoteState.Finished;
for (var i = 0; i < log.Options!.Count; i++)
{
log.Options[i].VoteCount = voteCounts[i];
}
await db.DbContext.SaveChangesAsync();
}
public async Task CustomVoteLogCancel(int voteId)
{
await using var db = await GetDb();
var log = await db.DbContext.CustomVoteLog.SingleAsync(v => v.Id == voteId);
log.State = CustomVoteState.Cancelled;
await db.DbContext.SaveChangesAsync();
}
#endregion
public abstract Task SendNotification(DatabaseNotification notification);
// SQLite returns DateTime as Kind=Unspecified, Npgsql actually knows for sure it's Kind=Utc.

View file

@ -329,6 +329,46 @@ namespace Content.Server.Database
Task SendNotification(DatabaseNotification notification);
#endregion
#region Custom vote log
/// <summary>
/// Log a new custom vote to the database.
/// </summary>
/// <param name="title">The player-facing title for the custom vote.</param>
/// <param name="roundId">The round ID this vote was initiated.</param>
/// <param name="initiator">The user ID of the admin that initiated the vote.</param>
/// <param name="options">The player-facing contents of each vote option.</param>
/// <remarks>
/// The created vote is initially in the <see cref="CustomVoteState.Active"/> state.
/// </remarks>
/// <returns>
/// The ID of the database entry,
/// for subsequent calls to <see cref="CustomVoteLogFinish"/> or <see cref="CustomVoteLogCancel"/>.
/// </returns>
Task<int> CustomVoteLogAdd(string title, int roundId, NetUserId? initiator, ImmutableArray<string> options);
/// <summary>
/// Mark a logged custom vote as finished.
/// </summary>
/// <param name="voteId">
/// The database ID of the custom vote, as returned by <see cref="CustomVoteLogAdd"/>.
/// </param>
/// <param name="voteCounts">
/// The counts each option received. The indexes are matched to the options given in
/// <see cref="CustomVoteLogAdd"/>.
/// </param>
Task CustomVoteLogFinish(int voteId, ImmutableArray<int> voteCounts);
/// <summary>
/// Mark a logged custom vote as canceled.
/// </summary>
/// <param name="voteId">
/// The database ID of the custom vote, as returned by <see cref="CustomVoteLogAdd"/>.
/// </param>
Task CustomVoteLogCancel(int voteId);
#endregion
}
/// <summary>
@ -997,6 +1037,28 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.SendNotification(notification));
}
public Task<int> CustomVoteLogAdd(
string title,
int roundId,
NetUserId? initiator,
ImmutableArray<string> options)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.CustomVoteLogAdd(title, roundId, initiator, options));
}
public Task CustomVoteLogFinish(int voteId, ImmutableArray<int> voteCounts)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.CustomVoteLogFinish(voteId, voteCounts));
}
public Task CustomVoteLogCancel(int voteId)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.CustomVoteLogCancel(voteId));
}
private async void HandleDatabaseNotification(DatabaseNotification notification)
{
lock (_notificationHandlers)

View file

@ -133,5 +133,10 @@ namespace Content.Server.GameTicking
UpdateRoundFlow(frameTime);
UpdateGameRules();
}
public static int GetRoundId(IEntitySystemManager esm)
{
return esm.GetEntitySystemOrNull<GameTicker>()?.RoundId ?? 0;
}
}
}

View file

@ -2,7 +2,9 @@ using System.Linq;
using Content.Server.Administration;
using Content.Server.Administration.Logs;
using Content.Server.Chat.Managers;
using Content.Server.Database;
using Content.Server.Discord.WebhookMessages;
using Content.Server.GameTicking;
using Content.Server.Voting.Managers;
using Content.Shared.Administration;
using Content.Shared.CCVar;
@ -71,12 +73,14 @@ namespace Content.Server.Voting
[Dependency] private IChatManager _chatManager = default!;
[Dependency] private VoteWebhooks _voteWebhooks = default!;
[Dependency] private IConfigurationManager _cfg = default!;
[Dependency] private IServerDbManager _dbManager = default!;
[Dependency] private IEntitySystemManager _esm = default!;
private const int MaxArgCount = 10;
public override string Command => "customvote";
public override void Execute(IConsoleShell shell, string argStr, string[] args)
public override async void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length < 3 || args.Length > MaxArgCount)
{
@ -106,9 +110,15 @@ namespace Content.Server.Voting
var vote = _voteManager.CreateVote(options);
var voteLogId = await _dbManager.CustomVoteLogAdd(
title,
GameTicker.GetRoundId(_esm),
shell.Player?.UserId,
[..options.Options.Select(x => x.text)]);
var webhookState = _voteWebhooks.CreateWebhookIfConfigured(options, _cfg.GetCVar(CCVars.DiscordVoteWebhook));
vote.OnFinished += (_, eventArgs) =>
vote.OnFinished += async (_, eventArgs) =>
{
if (eventArgs.Winner == null)
{
@ -123,11 +133,13 @@ namespace Content.Server.Voting
}
_voteWebhooks.UpdateWebhookIfConfigured(webhookState, eventArgs);
await _dbManager.CustomVoteLogFinish(voteLogId, [..eventArgs.Votes]);
};
vote.OnCancelled += _ =>
vote.OnCancelled += async _ =>
{
_voteWebhooks.UpdateCancelledWebhookIfConfigured(webhookState);
await _dbManager.CustomVoteLogCancel(voteLogId);
};
}