Skip to content

fix(campaign): return 409 on double claim instead of 200 - #667

Closed
scarface-dev1 wants to merge 1 commit into
ritik4ever:mainfrom
scarface-dev1:fix/583-double-claim-409
Closed

fix(campaign): return 409 on double claim instead of 200#667
scarface-dev1 wants to merge 1 commit into
ritik4ever:mainfrom
scarface-dev1:fix/583-double-claim-409

Conversation

@scarface-dev1

@scarface-dev1 scarface-dev1 commented Jul 28, 2026

Copy link
Copy Markdown

Closes #583

Summary

Fixes a bug where calling POST /api/campaigns/:id/claim on an already-claimed campaign returned 200 OK with a success response instead of rejecting the request.

Root Cause

reconcileOnChainClaim() in campaignStore.ts had a silent-return guard when claimedAt was already set:

if (campaign.claimedAt) {
  return campaign; // silently returned 200
}

Changes

backend/src/services/campaignStore.ts

  • Changed the claimedAt guard from return campaign to throw toServiceError('Campaign already claimed.', 409, 'CAMPAIGN_ALREADY_CLAIMED')
  • Follows the same error pattern as softDeleteCampaign (409 / ALREADY_DELETED)

backend/src/api.test.ts

  • Duplicate claim assertion: 200 -> 409 with error code/message verification
  • Added history checks: confirms exactly 1 claim event before and after duplicate

backend/src/services/tests/mutation.test.ts

  • Updated from 'second claim is idempotent' to 'second claim throws 409 CAMPAIGN_ALREADY_CLAIMED'

Acceptance Criteria

Criteria Status
Double claim returns 409 Conflict Done
Message says Campaign already claimed Done
Test covers double-claim scenario Done (HTTP + unit)
Claim event not duplicated in history Done (verified via GET history)

Security Notes

No security impact. The change enforces a correctness invariant. The error is properly caught by the existing Express error middleware and returned with a structured JSON error response.

Summary by CodeRabbit

  • Bug Fixes
    • Duplicate campaign claims now return a conflict error instead of succeeding silently.
    • Error responses identify already-claimed campaigns with a clear message and error code.
    • Duplicate attempts no longer create additional claim history events or alter the original claim timestamp.

Closes ritik4ever#583

The POST /api/campaigns/:id/claim endpoint now checks if the campaign
has already been claimed before processing. If claimedAt is already
set, the endpoint returns a 409 Conflict with CAMPAIGN_ALREADY_CLAIMED
error code.

Changes:
- campaignStore.ts: reconcileOnChainClaim throws toServiceError with
  409 / CAMPAIGN_ALREADY_CLAIMED instead of silently returning campaign
- api.test.ts: duplicate claim test expects 409 with error code/message
  verification, plus history check (1 claim event before/after)
- mutation.test.ts: "second claim is idempotent" updated to assert 409
  throw with "Campaign already claimed" message
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the ritik4ever's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The claim flow now rejects duplicate claims with 409 CAMPAIGN_ALREADY_CLAIMED. Service and API tests verify claimedAt remains unchanged and campaign history contains only one claimed event.

Changes

Campaign claim lifecycle

Layer / File(s) Summary
Duplicate claim guard and validation
backend/src/services/campaignStore.ts, backend/src/services/__tests__/mutation.test.ts, backend/src/api.test.ts
Already-claimed campaigns now throw a 409 CAMPAIGN_ALREADY_CLAIMED error. Tests verify the original claim state remains unchanged and no duplicate history event is recorded.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: strngecloud

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: returning 409 on duplicate campaign claims instead of 200.
Linked Issues check ✅ Passed The PR meets #583 by returning 409 with the expected message, adding double-claim tests, and preventing duplicate history events.
Out of Scope Changes check ✅ Passed The changes stay focused on double-claim handling, related tests, and campaign history consistency.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/services/campaignStore.ts`:
- Around line 1002-1004: Move claim detection into the existing db.transaction
by conditionally updating campaigns through the claim write using campaign id
and claimed_at IS NULL. Check the update’s affected-row count before recordEvent
and throw toServiceError with CAMPAIGN_ALREADY_CLAIMED when fewer than one row
was updated; remove reliance on the pre-transaction campaign.claimedAt check.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 17bc7fb7-ed5d-4ba1-a516-75f216de1a95

📥 Commits

Reviewing files that changed from the base of the PR and between 10f827c and d831b92.

📒 Files selected for processing (3)
  • backend/src/api.test.ts
  • backend/src/services/__tests__/mutation.test.ts
  • backend/src/services/campaignStore.ts

Comment on lines 1002 to 1004
if (campaign.claimedAt) {
return campaign;
throw toServiceError('Campaign already claimed.', 409, 'CAMPAIGN_ALREADY_CLAIMED');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching campaignStore:\n'
fd -a 'campaignStore\.ts$' . || true

file="$(fd 'campaignStore\.ts$' . | head -n1 || true)"
if [ -n "$file" ]; then
  printf '\nLine count:\n'
  wc -l "$file"
  printf '\nRelevant section around claim event:\n'
  sed -n '960,1045p' "$file" | nl -ba -v960
  printf '\nSchema/migrations mentioning claimed_at/campaigns:\n'
  rg -n --glob '*.sql' --glob '*.ts' --glob '*.js' 'claimed_at|CREATE TABLE .*campaign|campaigns' .
fi

Repository: ritik4ever/stellar-goal-vault

Length of output: 391


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="backend/src/services/campaignStore.ts"

printf 'Relevant section around claim event:\n'
sed -n '960,1045p' "$file"

printf '\nSchema/migrations mentioning claimed_at/campaigns:\n'
rg -n --glob '*.sql' --glob '*.ts' --glob '*.js' 'claimed_at|CREATE TABLE .*campaign|campaigns' .

printf '\nCheck for claimed_at uniqueness/index definitions:\n'
rg -n --glob '*.sql' --glob '*.ts' --glob '*.js' 'UNIQUE|INDEX| claimed_at|campaigns' .

Repository: ritik4ever/stellar-goal-vault

Length of output: 50385


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Campaign schema and migrations:\n'
sed -n '78,112p' backend/src/services/db.ts
sed -n '158,260p' backend/src/services/db.ts

printf '\nrecordEvent implementation/usages:\n'
rg -n "function recordEvent|const recordEvent|recordEvent\\(" backend/src/services/campaignStore.ts backend/src/services/db.ts

printf '\nConcurrent duplicate claim behavior probe (SQLite, no repository code executed):\n'
if command -v sqlite3 >/dev/null 2>&1; then
  tmp="$(mktemp)"
  cat > "$tmp" <<'SQL'
PRAGMA foreign_keys=off;
BEGIN;
CREATE TABLE campaign_events (
 campaign_id INTEGER PRIMARY KEY,
 event_type TEXT,
 event_data JSON NOT NULL
);
CREATE TABLE campaigns (id TEXT PRIMARY KEY, claimed_at INTEGER);
INSERT INTO campaigns(id, claimed_at) VALUES ('c1', NULL);
COMMIT;

PRAGMA foreign_keys=off;
BEGIN IMMEDIATE;
INSERT INTO campaign_events(campaign_id, event_type, event_data) VALUES ('c1', 'claimed', '{}');
UPDATE campaigns SET claimed_at = 2 WHERE id = 'c1' AND claimed_at IS NULL;
COMMIT;

SELECT sqlite3_changes();
SELECT event_data, campaign_id FROM campaign_events ORDER BY rowid;
SELECT claimed_at FROM campaigns WHERE id='c1';
SQL
 sqlite3 ":memory:" < "$tmp"
 rm -f "$tmp"
else
  echo "sqlite3 not available"
fi

Repository: ritik4ever/stellar-goal-vault

Length of output: 5228


Make claim detection atomic with the claim write.

campaign.claimedAt is read before db.transaction, so concurrent claimers can both observe an unclaimed campaign and append duplicate claimed events. Keep the current transaction shape but make the update conditional and fail before recordEvent:

UPDATE campaigns SET claimed_at = ? WHERE id = ? AND claimed_at IS NULL

Then throw CAMPAIGN_ALREADY_CLAIMED when the affected row count is less than 1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/services/campaignStore.ts` around lines 1002 - 1004, Move claim
detection into the existing db.transaction by conditionally updating campaigns
through the claim write using campaign id and claimed_at IS NULL. Check the
update’s affected-row count before recordEvent and throw toServiceError with
CAMPAIGN_ALREADY_CLAIMED when fewer than one row was updated; remove reliance on
the pre-transaction campaign.claimedAt check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] POST /api/campaigns/:id/claim does not check if campaign is already claimed

1 participant