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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/KitsuneCommand/Data/Repositories/VoteGrantRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ public interface IVoteGrantRepository
IEnumerable<VoteGrant> GetForPlayer(string steamId, int limit = 50);

int GetTotalCount();

/// <summary>
/// Removes a grant row by its idempotency key. Used as a rollback when
/// the reward dispatch step throws AFTER the audit row was inserted —
/// without this, a misconfigured reward (e.g. wrong VIP-gift template
/// name) would lock the player out of ever claiming, because every
/// future sweep would short-circuit on HasGrantForDate.
/// </summary>
int DeleteByKey(string provider, string steamId, string voteDate);
}

public class VoteGrantRepository : IVoteGrantRepository
Expand Down Expand Up @@ -89,5 +98,14 @@ public int GetTotalCount()
using var conn = _db.CreateConnection();
return conn.ExecuteScalar<int>("SELECT COUNT(*) FROM vote_grants");
}

public int DeleteByKey(string provider, string steamId, string voteDate)
{
using var conn = _db.CreateConnection();
return conn.Execute(@"
DELETE FROM vote_grants
WHERE provider = @Provider AND steam_id = @SteamId AND vote_date = @VoteDate",
new { Provider = provider, SteamId = steamId, VoteDate = voteDate });
}
}
}
28 changes: 23 additions & 5 deletions src/KitsuneCommand/Features/VoteRewardsFeature.cs
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,29 @@ private async Task<ClaimResult> TryGrantOnceAsync(
return result;
}

// STEP 2: grant the actual reward. If this fails the audit row
// is still in place — admin can see the failure in the log and
// intervene manually rather than the player getting a silent
// double-grant on retry.
var grantedDescription = DispatchReward(steamId, playerName, cfg);
// STEP 2: grant the actual reward. If dispatch throws (e.g. an
// admin typo'd the VIP-gift template name), we MUST roll back
// the audit row — otherwise HasGrantForDate will short-circuit
// every future sweep and the player is permanently locked out
// of claiming, even after the admin fixes the misconfiguration.
//
// The narrow try/catch here ensures we only roll back on a
// dispatch failure, not on the broader try below (which catches
// network errors from STEP 0 / STEP 3 — those don't leave an
// orphaned audit row).
string grantedDescription;
try
{
grantedDescription = DispatchReward(steamId, playerName, cfg);
}
catch (Exception dispatchEx)
{
_grantRepo.DeleteByKey(provider.Key, steamId, voteDate);
Log.Warning($"[KitsuneCommand] VoteRewards: dispatch failed for {steamId} via {provider.Key} — rolled back audit row so next sweep can retry. Cause: {dispatchEx.Message}");
result.Outcome = ClaimOutcome.Error;
result.Message = $"{provider.DisplayName}: dispatch failed — {dispatchEx.Message}";
return result;
}

// STEP 3: tell the listing site we delivered. Failure here is
// not catastrophic — next status check returns "unclaimed" again,
Expand Down
Loading