Repository navigation
feat(backend): top.gg vote webhook endpoint - #729
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 34 minutes and 6 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
📝 WalkthroughWalkthroughAdds Top.gg vote tracking: new shared constants, backend webhook and query routes with Redis-backed vote/streak persistence and auth checks, bot command to display vote rewards, and accompanying unit tests and .env example entry. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant TopGG as Top.gg
participant Backend as Backend Server
participant Redis as Redis
User->>TopGG: Cast vote
TopGG->>Backend: POST /webhooks/topgg-votes (auth header)
Backend->>Backend: verify TOPGG_AUTH_TOKEN
Backend->>Redis: SET votes:{userId} NX EX 12h
alt first vote
Backend->>Redis: INCR votes:streak:{userId}
Backend->>Redis: EXPIRE votes:streak:{userId} 36h
else duplicate
Backend-->Backend: return duplicate response
end
Backend->>TopGG: { ok: true }
sequenceDiagram
participant Bot as Discord Bot
participant Backend as Backend Server
participant Redis as Redis
Bot->>Backend: GET /api/me/vote-status (auth)
Backend->>Redis: GET votes:{userId}, TTL votes:{userId}, GET votes:streak:{userId}
Backend->>Backend: compute tier via tierForVoteStreak
Backend->>Bot: { hasVoted, streak, nextVoteInSeconds, tier, nextTier, voteUrl }
Bot->>User: display /voterewards embed
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Closes the top.gg vote loop:
- backend: adds GET /api/internal/votes/:userId behind LUCKY_NOTIFY_API_KEY
returning { hasVoted, streak, nextVoteInSeconds }. Keeps all vote-related
routes in the same file (webhooks.ts) but separates the internal read
(internal-key auth) from the external webhook (top.gg auth).
- bot: /voterewards slash command fetches the user's state and replies
with tier + streak + next-vote countdown. Graceful fallback when the
backend is unreachable or env vars are missing — surfaces the top.gg
vote link unconditionally so the command is always useful.
Tier table (matches docs/TOP_GG_SUBMISSION.md §6):
1+ streak → Lucky Supporter
7+ streak → Lucky Fan (early access to new commands)
14+ streak → Lucky Regular (autoplay weighting + early access)
30+ streak → Lucky Legend (dashboard badge + weighting + priority support)
Stacked on feature/topgg-vote-webhook (#729) — merge #729 first, then this
rebases cleanly.
Tests: 9 total in webhooks.test.ts (+ 3 new covering the GET endpoint).
Type-check: backend + bot both clean.
Closes the top.gg vote loop:
- backend: adds GET /api/internal/votes/:userId behind LUCKY_NOTIFY_API_KEY
returning { hasVoted, streak, nextVoteInSeconds }. Keeps all vote-related
routes in the same file (webhooks.ts) but separates the internal read
(internal-key auth) from the external webhook (top.gg auth).
- bot: /voterewards slash command fetches the user's state and replies
with tier + streak + next-vote countdown. Graceful fallback when the
backend is unreachable or env vars are missing — surfaces the top.gg
vote link unconditionally so the command is always useful.
Tier table (matches docs/TOP_GG_SUBMISSION.md §6):
1+ streak → Lucky Supporter
7+ streak → Lucky Fan (early access to new commands)
14+ streak → Lucky Regular (autoplay weighting + early access)
30+ streak → Lucky Legend (dashboard badge + weighting + priority support)
Stacked on feature/topgg-vote-webhook (#729) — merge #729 first, then this
rebases cleanly.
Tests: 9 total in webhooks.test.ts (+ 3 new covering the GET endpoint).
Type-check: backend + bot both clean.
Extends the top.gg flywheel to the authenticated web dashboard.
Authenticated user reads their own vote state + tier:
GET /api/me/vote-status (requireAuth)
→ {
hasVoted, streak, nextVoteInSeconds,
tier: { label, threshold } | null,
nextTier: { label, threshold } | null,
voteUrl: "https://top.gg/bot/.../vote"
}
Tier thresholds match the /voterewards slash command exactly:
1+ → Lucky Supporter
7+ → Lucky Fan
14+ → Lucky Regular
30+ → Lucky Legend
Reuses the existing readVoteState() helper — same Redis keys the webhook
writes to, so the three endpoints (POST webhook, GET internal, GET me)
stay in perfect sync. No schema change, no new env vars.
Stacked on feature/topgg-vote-webhook (builds on #729 + #730).
Tests: 3 new cases added to webhooks.test.ts (13 total now):
- 401 when not authenticated
- Streak-14 voter → tier=Regular, nextTier=Legend
- 0-streak user → tier=null, nextTier=Supporter
- 30+ streak → tier=Legend, nextTier=null
Extends the top.gg flywheel to the authenticated web dashboard.
Authenticated user reads their own vote state + tier:
GET /api/me/vote-status (requireAuth)
→ {
hasVoted, streak, nextVoteInSeconds,
tier: { label, threshold } | null,
nextTier: { label, threshold } | null,
voteUrl: "https://top.gg/bot/.../vote"
}
Tier thresholds match the /voterewards slash command exactly:
1+ → Lucky Supporter
7+ → Lucky Fan
14+ → Lucky Regular
30+ → Lucky Legend
Reuses the existing readVoteState() helper — same Redis keys the webhook
writes to, so the three endpoints (POST webhook, GET internal, GET me)
stay in perfect sync. No schema change, no new env vars.
Stacked on feature/topgg-vote-webhook (builds on #729 + #730).
Tests: 3 new cases added to webhooks.test.ts (13 total now):
- 401 when not authenticated
- Streak-14 voter → tier=Regular, nextTier=Legend
- 0-streak user → tier=null, nextTier=Supporter
- 30+ streak → tier=Legend, nextTier=null
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (4)
packages/backend/src/routes/webhooks.ts (2)
145-154: Consider requiringtype === 'upvote'explicitly before persisting.After the
type: 'test'branch, any payload with auserfield is persisted regardless oftype(unknown/missing types, future event types added by top.gg, etc.). Since the request is already auth-gated this is low-risk, but an explicit allowlist is more defensive and makes intent obvious:if (payload.type === 'test') { debugLog({ message: 'top.gg webhook test received' }) res.status(200).json({ ok: true, test: true }) return } + if (payload.type !== 'upvote') { + throw AppError.badRequest('unsupported webhook type') + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/backend/src/routes/webhooks.ts` around lines 145 - 154, After the 'test' branch, require payload.type === 'upvote' before proceeding: check payload.type (e.g., if (payload.type !== 'upvote')) and throw AppError.badRequest('unsupported webhook type') or simply return; only then validate userId and persist. Update the logic around the existing payload, payload.type and userId checks so unknown/missing/future types are rejected instead of allowing any request with a user field to be persisted.
10-22:VOTE_TIERSandtierForare duplicated across backend and bot.The identical table (thresholds 30/14/7/1, labels) and lookup function live in
packages/bot/src/functions/general/commands/voterewards.ts:22-46. Any future tier tweak requires editing two files in lockstep or the backend-computedtier/nextTierwill diverge from the bot's rendered tier. Consider moving the tier table +tierForto@lucky/sharedand importing it in both packages (the bot version can extend it with aperkfield locally, or move perks there too).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/backend/src/routes/webhooks.ts` around lines 10 - 22, The VOTE_TIERS constant and tierFor function are duplicated; extract VOTE_TIERS and tierFor into a single shared module (e.g. export them from `@lucky/shared`) and update both packages to import those symbols instead of declaring them locally; keep the bot's local file free of the duplicate and, if needed, extend the imported VOTE_TIERS with a local `perk` field or keep perks in the bot code while using the shared thresholds/lookup to compute tier/nextTier consistently across backend and bot.packages/bot/src/functions/general/commands/voterewards.ts (1)
7-8: Hard-coded bot ID duplicated with backendvoteUrl.
TOP_GG_BOT_ID=962198089161134131is also hard-coded inpackages/backend/src/routes/webhooks.ts:132. Same with the tier table (see the recommended-refactor comment onwebhooks.ts:10-22). Moving both to@lucky/shared/constants(alongsideCOLORthat's already imported here) would prevent drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/bot/src/functions/general/commands/voterewards.ts` around lines 7 - 8, Replace the duplicated hard-coded TOP_GG_BOT_ID and derived TOP_GG_VOTE_URL by moving the constant(s) into the shared constants module (e.g., add TOP_GG_BOT_ID and TOP_GG_VOTE_URL to `@lucky/shared/constants` alongside COLOR) and update references in this file (TOP_GG_BOT_ID, TOP_GG_VOTE_URL) and the backend's voteUrl usage to import and use those shared constants so the value is defined in one place.packages/backend/tests/unit/routes/webhooks.test.ts (1)
81-213: Nitpick: test grouping —GETblocks are nested inside thePOST /webhooks/topgg-votesdescribe.
describe('GET /api/me/vote-status', ...)(lines 155–213) and theGET /api/internal/votes/:userIditblocks (lines 124–153), as well as the successful upvote test (lines 215–234), all live under thedescribe('POST /webhooks/topgg-votes', ...)opened at line 81. Test output will read misleadingly. Consider lifting them to siblings:-describe('POST /webhooks/topgg-votes', () => { +describe('POST /webhooks/topgg-votes', () => { // ...existing POST-only cases... +}) + +describe('GET /api/internal/votes/:userId', () => { + // moved +}) + +describe('GET /api/me/vote-status', () => { + // moved +})Also, once idempotency is fixed on the route (see comment on
webhooks.ts:137-183), please add a test that POSTs the sameusertwice and assertsincris called exactly once.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/backend/tests/unit/routes/webhooks.test.ts` around lines 81 - 213, The GET tests are incorrectly nested inside the describe('POST /webhooks/topgg-votes') block—move the describe('GET /api/me/vote-status', ...) block and the GET /api/internal/votes/:userId it tests out to be sibling describes (i.e., close the POST describe before those GET tests) so output reads correctly; then add a new test under the POST describe that POSTs to '/webhooks/topgg-votes' twice with the same { user } payload and asserts the idempotency behavior by checking the mocked incr function is called exactly once (reference the test titles and the mocked incr symbol used in the file).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/backend/src/routes/index.ts`:
- Around line 85-86: The two API routes registered inside setupWebhookRoutes
(the endpoints /api/internal/votes/:userId and /api/me/vote-status) are being
added before app.use('/api/', apiLimiter) and thus bypass the rate limiter; move
the registration of those two routes so they are added after apiLimiter is
applied (either by splitting setupWebhookRoutes into setupWebhookPublicRoutes()
called before the limiter and setupWebhookApiRoutes() called after, or by
relocating the specific handlers for /api/internal/votes/:userId and
/api/me/vote-status to be registered after app.use('/api/', apiLimiter)); ensure
you only keep /webhooks/topgg-votes registered before the limiter if
intentional.
In `@packages/backend/src/routes/webhooks.ts`:
- Around line 57-74: Both verifyTopggAuth and verifyInternalKey use direct
string comparison which can leak timing info; replace those comparisons with a
constant-time comparison using crypto.timingSafeEqual: load the expected value
from process.env and the provided header, convert both to Buffers (or return
unauthorized if either is missing), check lengths first and if lengths differ
return unauthorized, then call crypto.timingSafeEqual on the buffers and
continue/throw the same AppError on mismatch; update verifyTopggAuth (uses
'authorization' header and TOPGG_AUTH_TOKEN) and verifyInternalKey (uses
'x-notify-key' and LUCKY_NOTIFY_API_KEY) accordingly.
- Around line 137-183: The handler currently always INCRs votes:streak:<userId>
and ignores per-command pipeline errors, causing duplicate streaks and lost
TTLs; modify the logic in the POST '/webhooks/topgg-votes' handler (inside
verifyTopggAuth block using getRedis(), voteKey and streakKey) to perform the
vote-key SET with NX (only set if not exists) and then conditionally INCR/EXPIRE
the streak only when the SET returned a successful OK, and after pipeline.exec()
inspect the pipeline replies for per-op errors (handle or log and rollback if
needed); ensure you return 200 for duplicate deliveries without incrementing the
streak and explicitly set/verify the streakKey TTL when incrementing,
throwing/logging and responding appropriately on pipeline-level failures.
- Around line 31-47: The getRedis function currently throws new AppError(500,
...) and returns a Redis client without an 'error' listener; update the
REDIS_HOST check to throw AppError.serviceUnavailable('REDIS_HOST is not
configured') and, after creating redisClient in getRedis, attach a no-op/logging
error handler (e.g., redisClient.on('error', (err) => /* log or ignore */)) so
ioredis errors don't become uncaught exceptions; refer to symbols getRedis,
redisClient, Redis, and AppError when making the changes.
In `@packages/bot/src/functions/general/commands/voterewards.ts`:
- Around line 62-80: The fetchVoteState function makes a fetch without a timeout
which can block Discord's 3s interaction window; update the fetch call inside
fetchVoteState to pass an AbortSignal (e.g., signal: AbortSignal.timeout(2500))
so the request is aborted if it exceeds ~2.5s, and handle the abort like other
errors (the existing catch will log and return null). Modify the fetch
invocation in fetchVoteState to include the signal option while keeping headers
the same; choose a timeout under 3000ms to allow interaction deferral to
proceed.
---
Nitpick comments:
In `@packages/backend/src/routes/webhooks.ts`:
- Around line 145-154: After the 'test' branch, require payload.type ===
'upvote' before proceeding: check payload.type (e.g., if (payload.type !==
'upvote')) and throw AppError.badRequest('unsupported webhook type') or simply
return; only then validate userId and persist. Update the logic around the
existing payload, payload.type and userId checks so unknown/missing/future types
are rejected instead of allowing any request with a user field to be persisted.
- Around line 10-22: The VOTE_TIERS constant and tierFor function are
duplicated; extract VOTE_TIERS and tierFor into a single shared module (e.g.
export them from `@lucky/shared`) and update both packages to import those symbols
instead of declaring them locally; keep the bot's local file free of the
duplicate and, if needed, extend the imported VOTE_TIERS with a local `perk`
field or keep perks in the bot code while using the shared thresholds/lookup to
compute tier/nextTier consistently across backend and bot.
In `@packages/backend/tests/unit/routes/webhooks.test.ts`:
- Around line 81-213: The GET tests are incorrectly nested inside the
describe('POST /webhooks/topgg-votes') block—move the describe('GET
/api/me/vote-status', ...) block and the GET /api/internal/votes/:userId it
tests out to be sibling describes (i.e., close the POST describe before those
GET tests) so output reads correctly; then add a new test under the POST
describe that POSTs to '/webhooks/topgg-votes' twice with the same { user }
payload and asserts the idempotency behavior by checking the mocked incr
function is called exactly once (reference the test titles and the mocked incr
symbol used in the file).
In `@packages/bot/src/functions/general/commands/voterewards.ts`:
- Around line 7-8: Replace the duplicated hard-coded TOP_GG_BOT_ID and derived
TOP_GG_VOTE_URL by moving the constant(s) into the shared constants module
(e.g., add TOP_GG_BOT_ID and TOP_GG_VOTE_URL to `@lucky/shared/constants`
alongside COLOR) and update references in this file (TOP_GG_BOT_ID,
TOP_GG_VOTE_URL) and the backend's voteUrl usage to import and use those shared
constants so the value is defined in one place.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 20fb7007-2cf2-4a41-9dc0-b772941eeb78
📒 Files selected for processing (5)
.env.examplepackages/backend/src/routes/index.tspackages/backend/src/routes/webhooks.tspackages/backend/tests/unit/routes/webhooks.test.tspackages/bot/src/functions/general/commands/voterewards.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: Security
- GitHub Check: Quality Gates
- GitHub Check: SonarCloud Scan
🔇 Additional comments (1)
.env.example (1)
202-205: LGTM.Documentation matches the backend behavior (commented-out optional var; endpoint returns 503 when unset).
…+ redis error listener Addresses CodeRabbit feedback on #729: - **Idempotency**: top.gg retries webhook deliveries on handler failure/timeout. The previous pipeline INCR'd votes:streak:<userId> on every successful POST, double-counting the streak on retries. Switched to SET NX as the idempotency gate — only the first write within the 12h vote window increments the streak; subsequent retries return { ok: true, duplicate: true } without touching the counter. - **Timing-safe compare**: replaced raw !== on TOPGG_AUTH_TOKEN and LUCKY_NOTIFY_API_KEY with crypto.timingSafeEqual via a length-checked safeEqualString helper. Prevents length/prefix timing leaks over public internet. - **Redis error listener**: attach .on('error', ...) to the cached ioredis client. Without this, connection failures during retry raise uncaughtException; we log and swallow since maxRetriesPerRequest=1 already surfaces errors per-request. - **Explicit vote type check**: reject payloads where type is neither 'test' nor 'upvote'. Prevents future top.gg event types (e.g. downvotes) from silently incrementing streaks. Tests: added 2 new cases (duplicate vote, unsupported type) + updated the existing "records upvote" assertion to check SET NX. 15/15 passing.
|
Addressed all three substantive CodeRabbit findings in 6604adc6:
Tests: added 2 new cases (duplicate vote idempotency, unsupported type rejection) + updated the existing "records upvote" assertion to check SET NX. 15/15 passing. The |
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
packages/backend/src/routes/webhooks.ts (1)
187-208:⚠️ Potential issue | 🟠 MajorMake the idempotency gate and streak update one atomic Redis operation.
SET NXsucceeds before the streak pipeline runs. Ifincr/expirefails or returns a command error, the retry is treated as a duplicate and the streak is permanently skipped. A plain pipeline also does not provide rollback/atomicity. This is the unresolved Redis failure mode from the earlier idempotency review.🛠️ Proposed fix using one Redis script
Add the script near the TTL constants:
const STREAK_TTL_SECONDS = 60 * 60 * 36 + +const RECORD_VOTE_SCRIPT = ` +if redis.call('EXISTS', KEYS[1]) == 1 then + return 0 +end +redis.call('INCR', KEYS[2]) +redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3])) +redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2])) +return 1 +`Then replace the split write:
- const firstWrite = await redis.set( + const recordResult = await redis.eval( + RECORD_VOTE_SCRIPT, + 2, voteKey, + streakKey, Date.now().toString(), - 'EX', - VOTE_TTL_SECONDS, - 'NX', + String(VOTE_TTL_SECONDS), + String(STREAK_TTL_SECONDS), ) - if (firstWrite !== 'OK') { + if (Number(recordResult) !== 1) { debugLog({ message: 'top.gg vote already recorded — skipping streak increment', @@ return } - - const pipeline = redis.pipeline() - pipeline.incr(streakKey) - pipeline.expire(streakKey, STREAK_TTL_SECONDS) - await pipeline.exec() debugLog({Does Redis pipeline provide atomic execution or rollback? For ioredis pipeline().exec(), are per-command errors returned in [err, result] tuples? Is Redis EVAL executed atomically with respect to other clients?🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/backend/src/routes/webhooks.ts` around lines 187 - 208, The current flow uses SET NX (voteKey) and then a separate pipeline.incr/expire (streakKey), which is not atomic and can leave the vote recorded but streak not incremented; replace this with a single Redis EVAL'd Lua script (or SCRIPT LOAD + EVALSHA) that atomically: checks/sets voteKey with EX and NX (using KEYS[1], VOTE_TTL_SECONDS), and if that set succeeded performs INCR on streakKey and EXPIRE on streakKey (using KEYS[2], STREAK_TTL_SECONDS), then returns a clear result indicating duplicate vs success; update the call sites that currently use redis.set(... 'NX') and pipeline.exec() (referencing voteKey, streakKey, VOTE_TTL_SECONDS, STREAK_TTL_SECONDS, redis.pipeline(), pipeline.exec()) to call the script and handle the script’s return to send duplicate vs ok responses and surface Redis errors instead of treating failures as duplicates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/backend/src/routes/webhooks.ts`:
- Around line 173-180: The code accepts any string for payload.user and then
builds Redis keys voteKey and streakKey which can collide (e.g., "streak:123");
validate payload.user is a valid Discord snowflake (numeric string of expected
length/format) before assigning userId, and throw AppError.badRequest if it
fails; update the validation logic near payload.user / userId (the
AppError.badRequest check) to use a helper or regex that enforces numeric
snowflake form, then only call getRedis() and build voteKey and streakKey when
the validated userId is safe.
- Around line 89-103: The pipeline exec currently discards per-command errors
via the pattern [[, voteTs], [, streakRaw], [, ttl]] which hides Redis command
errors; change the destructuring to capture each exec tuple (e.g., const
[voteRes, streakRes, ttlRes] = await redis.pipeline(...).exec()), check
voteRes[0], streakRes[0], ttlRes[0] for non-null Error and throw or return that
error with context (include voteKey/streakKey and command name), then read
results from voteRes[1], streakRes[1], ttlRes[1] and parse them (convert
streakRaw to Number and normalize ttl to non-negative) before returning
hasVoted, streak and nextVoteInSeconds; ensure you reference the existing
voteKey, streakKey, pipeline and exec usage when making the changes so
per-command errors are surfaced instead of silently returning defaults.
In `@packages/bot/src/functions/general/commands/voterewards.ts`:
- Around line 74-80: formatNextVoteIn currently shows "in 0m" for 1–59 seconds;
update the function to round up remaining seconds to whole minutes so sub-minute
values display "in 1m" and to handle minute-rollover into hours. Specifically,
in formatNextVoteIn(seconds) keep the early return for seconds <= 0, compute
hours = Math.floor(seconds / 3600), compute remaining = seconds - hours * 3600,
set minutes = Math.ceil(remaining / 60), if minutes === 60 then hours += 1 and
minutes = 0, then return `in ${hours}h ${minutes}m` when hours > 0 (omitting
minutes if 0 if you prefer) otherwise return `in ${minutes}m`; this ensures
1–59s become "in 1m" and hour boundaries are handled.
---
Duplicate comments:
In `@packages/backend/src/routes/webhooks.ts`:
- Around line 187-208: The current flow uses SET NX (voteKey) and then a
separate pipeline.incr/expire (streakKey), which is not atomic and can leave the
vote recorded but streak not incremented; replace this with a single Redis
EVAL'd Lua script (or SCRIPT LOAD + EVALSHA) that atomically: checks/sets
voteKey with EX and NX (using KEYS[1], VOTE_TTL_SECONDS), and if that set
succeeded performs INCR on streakKey and EXPIRE on streakKey (using KEYS[2],
STREAK_TTL_SECONDS), then returns a clear result indicating duplicate vs
success; update the call sites that currently use redis.set(... 'NX') and
pipeline.exec() (referencing voteKey, streakKey, VOTE_TTL_SECONDS,
STREAK_TTL_SECONDS, redis.pipeline(), pipeline.exec()) to call the script and
handle the script’s return to send duplicate vs ok responses and surface Redis
errors instead of treating failures as duplicates.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: acab5539-2d44-478e-a4ef-35c01a1373a7
📒 Files selected for processing (8)
packages/backend/src/routes/index.tspackages/backend/src/routes/webhooks.tspackages/backend/tests/unit/routes/index.test.tspackages/backend/tests/unit/routes/webhooks.test.tspackages/bot/src/functions/general/commands/voterewards.spec.tspackages/bot/src/functions/general/commands/voterewards.tspackages/shared/src/constants/index.tspackages/shared/src/constants/topgg.ts
✅ Files skipped from review due to trivial changes (3)
- packages/shared/src/constants/index.ts
- packages/bot/src/functions/general/commands/voterewards.spec.ts
- packages/shared/src/constants/topgg.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/backend/src/routes/index.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Quality Gates
- GitHub Check: SonarCloud Scan
🔇 Additional comments (3)
packages/backend/tests/unit/routes/index.test.ts (1)
25-26: Good coverage for the split webhook route registration.The new mocks and call-order assertions protect the intended public-before-
apiLimiter/ API-after-apiLimiterwiring.Also applies to: 117-120, 161-214
packages/bot/src/functions/general/commands/voterewards.ts (1)
51-72: Fetch path looks resilient.The command handles missing config, non-OK responses, JSON fetch failures, and uses a sub-3s abort timeout before replying.
packages/backend/tests/unit/routes/webhooks.test.ts (1)
87-263: Solid route-level coverage for the new webhook contract.The suite isolates Redis and covers auth, test payloads, unsupported types, internal reads, authenticated status, and duplicate vote behavior.
Adds POST /webhooks/topgg-votes to persist vote events for the upcoming
top.gg listing. Locked behind TOPGG_AUTH_TOKEN — returns 503 when unset
so the endpoint is inert until the listing goes live.
On each upvote:
- votes:<userId> — 12h TTL (matches top.gg's 12h vote window)
- votes:streak:<userId> — incremented, 36h TTL (12h grace around the
24h cycle so daily voters don't lose streaks)
Returns { ok: true, test: true } for top.gg's test pings.
Wiring: registered in routes/index.ts alongside the other non-API
endpoints (health, stats, internal notify). No /api/ prefix — matches
top.gg's expected webhook URL shape.
Env: TOPGG_AUTH_TOKEN added to .env.example (commented out).
Tests: 6 unit tests covering missing/wrong auth, test-type payload,
missing user id, and a successful upvote (verifies pipeline ops).
Follow-up (next PR): /voterewards slash command in packages/bot that
reads votes:streak:<user> and grants tier benefits. See
docs/TOP_GG_SUBMISSION.md §6 for the broader wiring plan.
Closes the top.gg vote loop:
- backend: adds GET /api/internal/votes/:userId behind LUCKY_NOTIFY_API_KEY
returning { hasVoted, streak, nextVoteInSeconds }. Keeps all vote-related
routes in the same file (webhooks.ts) but separates the internal read
(internal-key auth) from the external webhook (top.gg auth).
- bot: /voterewards slash command fetches the user's state and replies
with tier + streak + next-vote countdown. Graceful fallback when the
backend is unreachable or env vars are missing — surfaces the top.gg
vote link unconditionally so the command is always useful.
Tier table (matches docs/TOP_GG_SUBMISSION.md §6):
1+ streak → Lucky Supporter
7+ streak → Lucky Fan (early access to new commands)
14+ streak → Lucky Regular (autoplay weighting + early access)
30+ streak → Lucky Legend (dashboard badge + weighting + priority support)
Stacked on feature/topgg-vote-webhook (#729) — merge #729 first, then this
rebases cleanly.
Tests: 9 total in webhooks.test.ts (+ 3 new covering the GET endpoint).
Type-check: backend + bot both clean.
Extends the top.gg flywheel to the authenticated web dashboard.
Authenticated user reads their own vote state + tier:
GET /api/me/vote-status (requireAuth)
→ {
hasVoted, streak, nextVoteInSeconds,
tier: { label, threshold } | null,
nextTier: { label, threshold } | null,
voteUrl: "https://top.gg/bot/.../vote"
}
Tier thresholds match the /voterewards slash command exactly:
1+ → Lucky Supporter
7+ → Lucky Fan
14+ → Lucky Regular
30+ → Lucky Legend
Reuses the existing readVoteState() helper — same Redis keys the webhook
writes to, so the three endpoints (POST webhook, GET internal, GET me)
stay in perfect sync. No schema change, no new env vars.
Stacked on feature/topgg-vote-webhook (builds on #729 + #730).
Tests: 3 new cases added to webhooks.test.ts (13 total now):
- 401 when not authenticated
- Streak-14 voter → tier=Regular, nextTier=Legend
- 0-streak user → tier=null, nextTier=Supporter
- 30+ streak → tier=Legend, nextTier=null
…+ redis error listener Addresses CodeRabbit feedback on #729: - **Idempotency**: top.gg retries webhook deliveries on handler failure/timeout. The previous pipeline INCR'd votes:streak:<userId> on every successful POST, double-counting the streak on retries. Switched to SET NX as the idempotency gate — only the first write within the 12h vote window increments the streak; subsequent retries return { ok: true, duplicate: true } without touching the counter. - **Timing-safe compare**: replaced raw !== on TOPGG_AUTH_TOKEN and LUCKY_NOTIFY_API_KEY with crypto.timingSafeEqual via a length-checked safeEqualString helper. Prevents length/prefix timing leaks over public internet. - **Redis error listener**: attach .on('error', ...) to the cached ioredis client. Without this, connection failures during retry raise uncaughtException; we log and swallow since maxRetriesPerRequest=1 already surfaces errors per-request. - **Explicit vote type check**: reject payloads where type is neither 'test' nor 'upvote'. Prevents future top.gg event types (e.g. downvotes) from silently incrementing streaks. Tests: added 2 new cases (duplicate vote, unsupported type) + updated the existing "records upvote" assertion to check SET NX. 15/15 passing.
a92dcf5 to
959d450
Compare
|
…ce (#775) * docs(backlog): mark completed 2026-04-21 backlog items with PR evidence 10 of the 16 tasks in docs/specs/2026-04-21-backlog-map/tasks.md already shipped — the unchecked state was sending future agents down dead ends. Splits the list into Open / Completed, cites the landing PR for each done item, and promotes the truly-remaining work to the top. Completed: - A-R2 token aliases (#763) - A-R3 SectionHeader eyebrowIcon + statusBand (#765) - PR #729 webhook coverage (ef06904) - tar Dependabot alerts (#766, replacing #755 which was closed) - PR queue rebase sweep — 14 PRs landed 2026-04-22 - Shared Top.gg constants (already in packages/shared/src/constants/topgg.ts) - validateBody + unskipped tests (#769) - Frontend token/font decision (#763) - Sonar coverage on #737 and #740 (both merged) Still open: follow-redirects/hono lock refresh, release/tag drift (production-visible), autoplay diversity dedup, queueManipulation refactor, /integrations page collapse (B-R4), BRANDING_GUIDE update (D-R2), per-page ports (13 pages). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(backlog): normalize version prefix (v2.6.148) per CodeRabbit Use `v` prefix consistently alongside `v2.6.132`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(backend): top.gg vote webhook endpoint
Adds POST /webhooks/topgg-votes to persist vote events for the upcoming
top.gg listing. Locked behind TOPGG_AUTH_TOKEN — returns 503 when unset
so the endpoint is inert until the listing goes live.
On each upvote:
- votes:<userId> — 12h TTL (matches top.gg's 12h vote window)
- votes:streak:<userId> — incremented, 36h TTL (12h grace around the
24h cycle so daily voters don't lose streaks)
Returns { ok: true, test: true } for top.gg's test pings.
Wiring: registered in routes/index.ts alongside the other non-API
endpoints (health, stats, internal notify). No /api/ prefix — matches
top.gg's expected webhook URL shape.
Env: TOPGG_AUTH_TOKEN added to .env.example (commented out).
Tests: 6 unit tests covering missing/wrong auth, test-type payload,
missing user id, and a successful upvote (verifies pipeline ops).
Follow-up (next PR): /voterewards slash command in packages/bot that
reads votes:streak:<user> and grants tier benefits. See
docs/TOP_GG_SUBMISSION.md §6 for the broader wiring plan.
* feat(bot): /voterewards command + GET /api/internal/votes/:userId (#730)
Closes the top.gg vote loop:
- backend: adds GET /api/internal/votes/:userId behind LUCKY_NOTIFY_API_KEY
returning { hasVoted, streak, nextVoteInSeconds }. Keeps all vote-related
routes in the same file (webhooks.ts) but separates the internal read
(internal-key auth) from the external webhook (top.gg auth).
- bot: /voterewards slash command fetches the user's state and replies
with tier + streak + next-vote countdown. Graceful fallback when the
backend is unreachable or env vars are missing — surfaces the top.gg
vote link unconditionally so the command is always useful.
Tier table (matches docs/TOP_GG_SUBMISSION.md §6):
1+ streak → Lucky Supporter
7+ streak → Lucky Fan (early access to new commands)
14+ streak → Lucky Regular (autoplay weighting + early access)
30+ streak → Lucky Legend (dashboard badge + weighting + priority support)
Stacked on feature/topgg-vote-webhook (#729) — merge #729 first, then this
rebases cleanly.
Tests: 9 total in webhooks.test.ts (+ 3 new covering the GET endpoint).
Type-check: backend + bot both clean.
* feat(backend): GET /api/me/vote-status for the dashboard (#736)
Extends the top.gg flywheel to the authenticated web dashboard.
Authenticated user reads their own vote state + tier:
GET /api/me/vote-status (requireAuth)
→ {
hasVoted, streak, nextVoteInSeconds,
tier: { label, threshold } | null,
nextTier: { label, threshold } | null,
voteUrl: "https://top.gg/bot/.../vote"
}
Tier thresholds match the /voterewards slash command exactly:
1+ → Lucky Supporter
7+ → Lucky Fan
14+ → Lucky Regular
30+ → Lucky Legend
Reuses the existing readVoteState() helper — same Redis keys the webhook
writes to, so the three endpoints (POST webhook, GET internal, GET me)
stay in perfect sync. No schema change, no new env vars.
Stacked on feature/topgg-vote-webhook (builds on #729 + #730).
Tests: 3 new cases added to webhooks.test.ts (13 total now):
- 401 when not authenticated
- Streak-14 voter → tier=Regular, nextTier=Legend
- 0-streak user → tier=null, nextTier=Supporter
- 30+ streak → tier=Legend, nextTier=null
* fix(webhooks): idempotent top.gg upvotes + timing-safe token compare + redis error listener
Addresses CodeRabbit feedback on #729:
- **Idempotency**: top.gg retries webhook deliveries on handler failure/timeout.
The previous pipeline INCR'd votes:streak:<userId> on every successful POST,
double-counting the streak on retries. Switched to SET NX as the idempotency
gate — only the first write within the 12h vote window increments the streak;
subsequent retries return { ok: true, duplicate: true } without touching the
counter.
- **Timing-safe compare**: replaced raw !== on TOPGG_AUTH_TOKEN and
LUCKY_NOTIFY_API_KEY with crypto.timingSafeEqual via a length-checked
safeEqualString helper. Prevents length/prefix timing leaks over public
internet.
- **Redis error listener**: attach .on('error', ...) to the cached ioredis
client. Without this, connection failures during retry raise
uncaughtException; we log and swallow since maxRetriesPerRequest=1 already
surfaces errors per-request.
- **Explicit vote type check**: reject payloads where type is neither 'test'
nor 'upvote'. Prevents future top.gg event types (e.g. downvotes) from
silently incrementing streaks.
Tests: added 2 new cases (duplicate vote, unsupported type) + updated the
existing "records upvote" assertion to check SET NX. 15/15 passing.
* fix: gate vote state routes behind api limiter
* fix: make topgg vote writes atomic
…ce (#775) * docs(backlog): mark completed 2026-04-21 backlog items with PR evidence 10 of the 16 tasks in docs/specs/2026-04-21-backlog-map/tasks.md already shipped — the unchecked state was sending future agents down dead ends. Splits the list into Open / Completed, cites the landing PR for each done item, and promotes the truly-remaining work to the top. Completed: - A-R2 token aliases (#763) - A-R3 SectionHeader eyebrowIcon + statusBand (#765) - PR #729 webhook coverage (a8edbe8) - tar Dependabot alerts (#766, replacing #755 which was closed) - PR queue rebase sweep — 14 PRs landed 2026-04-22 - Shared Top.gg constants (already in packages/shared/src/constants/topgg.ts) - validateBody + unskipped tests (#769) - Frontend token/font decision (#763) - Sonar coverage on #737 and #740 (both merged) Still open: follow-redirects/hono lock refresh, release/tag drift (production-visible), autoplay diversity dedup, queueManipulation refactor, /integrations page collapse (B-R4), BRANDING_GUIDE update (D-R2), per-page ports (13 pages). * docs(backlog): normalize version prefix (v2.6.148) per CodeRabbit Use `v` prefix consistently alongside `v2.6.132`. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>



Summary
Adds `POST /webhooks/topgg-votes` to persist vote events for the upcoming top.gg listing (Phase 0 of the next-phases ultraplan).
Locked behind `TOPGG_AUTH_TOKEN` — returns 503 when unset, so the endpoint is inert until the listing actually goes live.
Behaviour
Wiring
Env
Test plan
Follow-up
Next PR will add `/voterewards` slash command in `packages/bot` that reads `votes:streak:` and grants tier benefits. See `docs/TOP_GG_SUBMISSION.md` §6 for the broader wiring plan.
Summary by CodeRabbit
New Features
/voterewardsDiscord command showing vote status, streak, current/next tier, and vote linkTests
Chores