Skip to content

fix: Prevent Duplicate Spam Credit Penalties - #1463

Open
mitgajera wants to merge 1 commit into
SuperteamDAO:mainfrom
mitgajera:fix/credit-penalty
Open

fix: Prevent Duplicate Spam Credit Penalties#1463
mitgajera wants to merge 1 commit into
SuperteamDAO:mainfrom
mitgajera:fix/credit-penalty

Conversation

@mitgajera

@mitgajera mitgajera commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Prevents duplicate credit penalties when a submission or grant application is marked as Spam more than once.

Changes

  • Made spam penalty creation idempotent for submissions and grant applications.
  • Added per-record Redis locks around spam penalty creation to avoid concurrent duplicate ledger entries.
  • Skips duplicate spam-credit notification emails when a submission already has a spam penalty.

Validation

  • npm run lint passed.
  • git diff --check passed, only CRLF warnings.
  • npm run check-types is currently blocked by unrelated existing type errors outside this change.

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate spam-penalty credits when the same action is processed more than once.
    • Added safeguards to handle concurrent penalty processing reliably.
    • Avoided sending duplicate email notifications when no new penalty is created.
    • Spam penalties now apply only after confirming the submission is correctly labeled.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Spam penalty allocation now uses Redis locks and existing-penalty checks. Allocation functions return whether they created a penalty. Spam labeling uses this result to skip duplicate penalty emails.

Changes

Spam penalty idempotency

Layer / File(s) Summary
Locked penalty allocation
src/features/credits/utils/allocateCredits.ts
addSpamPenaltyCredit and addSpamPenaltyGrant now use scoped Redis locks, check existing penalties, and return CreditPenaltyResult.
Spam label result handling
src/pages/api/sponsor-dashboard/submission/update-label.ts
The spam-label flow records the update and skips email queuing when no penalty credit is created.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SubmissionLabelFlow
  participant addSpamPenaltyCredit
  participant Redis
  participant PenaltyLedger
  participant EmailQueue

  SubmissionLabelFlow->>addSpamPenaltyCredit: Process spam-labeled submission
  addSpamPenaltyCredit->>Redis: Acquire submission-specific lock
  Redis-->>addSpamPenaltyCredit: Lock result
  addSpamPenaltyCredit->>PenaltyLedger: Check or create penalty
  PenaltyLedger-->>addSpamPenaltyCredit: Return penalty status
  addSpamPenaltyCredit-->>SubmissionLabelFlow: Return created status
  alt Credit created
    SubmissionLabelFlow->>EmailQueue: Queue spam-credit email
  else Credit not created
    SubmissionLabelFlow->>SubmissionLabelFlow: Record update
  end
Loading

Suggested reviewers: revtpark

Poem

A rabbit guards the credit gate,
With Redis locks that make duplicates wait.
New penalties hop into the queue,
Old ones leave no email due.
“Created,” says the carrot clerk—
Idempotent magic at work!

🚥 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 and concisely describes the main change: preventing duplicate spam credit penalties.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/features/credits/utils/allocateCredits.ts (1)

10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make created readonly.

CreditPenaltyResult is an object type. Its properties should be readonly by default.

-type CreditPenaltyResult = { created: boolean };
+type CreditPenaltyResult = { readonly created: boolean };

As per coding guidelines, use readonly properties for object types by default.

🤖 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 `@src/features/credits/utils/allocateCredits.ts` at line 10, Update the
CreditPenaltyResult type so its created property is declared readonly,
preserving the existing boolean type and object shape.

Source: Coding guidelines

🤖 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 `@src/features/credits/utils/allocateCredits.ts`:
- Around line 114-116: Update the LockNotAcquiredError handling in
allocateCredits to return a distinct retryable lock-contention result rather
than `{ created: false }`; apply the same change to the corresponding branch
near the later catch block. Model the mutually exclusive outcomes with a
discriminated union, and reserve the duplicate result for cases where an
existing penalty is confirmed.
- Around line 77-113: Add composite unique constraints for (submissionId, type)
and (applicationId, type) to the CreditLedger schema, then update the spam
penalty creation flow around withRedisLock to catch Prisma unique-constraint
conflicts from creditLedger.create and return { created: false }; preserve
existing behavior for successful inserts and unrelated errors.

---

Nitpick comments:
In `@src/features/credits/utils/allocateCredits.ts`:
- Line 10: Update the CreditPenaltyResult type so its created property is
declared readonly, preserving the existing boolean type and object shape.
🪄 Autofix

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 Plus

Run ID: e84fd474-5396-413f-ba1d-7482aa2b6382

📥 Commits

Reviewing files that changed from the base of the PR and between af39bd1 and 548aca6.

📒 Files selected for processing (2)
  • src/features/credits/utils/allocateCredits.ts
  • src/pages/api/sponsor-dashboard/submission/update-label.ts

Comment on lines +77 to +113
return await withRedisLock(
`locks:credit-penalty:submission:${submissionId}`,
async () => {
const submission = await prisma.submission.findFirst({
where: { id: submissionId, label: SubmissionLabels.Spam },
select: { id: true, userId: true },
});

if (!submission) {
throw new Error('Submission not found');
}
if (!submission) {
throw new Error('Submission not found');
}

await prisma.creditLedger.create({
data: {
userId: submission.userId,
submissionId: submission.id,
type: CreditEventType.SPAM_PENALTY,
effectiveMonth: nextMonth,
change: -1,
const existingPenalty = await prisma.creditLedger.findFirst({
where: {
submissionId: submission.id,
type: CreditEventType.SPAM_PENALTY,
},
select: { id: true },
});

if (existingPenalty) {
return { created: false };
}

await prisma.creditLedger.create({
data: {
userId: submission.userId,
submissionId: submission.id,
type: CreditEventType.SPAM_PENALTY,
effectiveMonth: nextMonth,
change: -1,
},
});

return { created: true };
},
});
);

Copy link
Copy Markdown
Contributor

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

while IFS= read -r schema; do
  echo "=== $schema ==="
  rg -n -C 8 'model CreditLedger|@@unique|`@unique`|submissionId|applicationId|type' "$schema"
done < <(fd -HI --type f '^schema\.prisma$' .)

echo '=== allocation conflict handling ==='
rg -n -C 5 'P2002|creditLedger\.(findFirst|create)' \
  src/features/credits/utils/allocateCredits.ts

Repository: SuperteamDAO/earn

Length of output: 207


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== schema files ==="
fd -HI --type f '^schema\.prisma$' . || true

echo "=== CreditLedger references in schema files ==="
for schema in $(fd -HI --type f '^schema\.prisma$' .); do
  echo "=== $schema ==="
  rg -n -C 8 'model CreditLedger|@@unique|`@unique`|submissionId|applicationId|type' "$schema" || true
done

echo "=== allocation conflict handling ==="
rg -n -C 5 'P2002|creditLedger\.(findFirst|create)|withRedisLock|create\(' src/features/credits/utils/allocateCredits.ts || true

echo "=== full relevant allocateCredits excerpt ==="
sed -n '1,190p' src/features/credits/utils/allocateCredits.ts | nl -ba

Repository: SuperteamDAO/earn

Length of output: 36943


Make spam penalty creation idempotent at the database level.

CreditLedger has indexes on submissionId and applicationId, but not composite unique constraints. Add @@unique([submissionId, type]) for submissions and a matching (applicationId, type) constraint for grant applications, then catch Prisma unique-conflict results and return { created: false }.

🤖 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 `@src/features/credits/utils/allocateCredits.ts` around lines 77 - 113, Add
composite unique constraints for (submissionId, type) and (applicationId, type)
to the CreditLedger schema, then update the spam penalty creation flow around
withRedisLock to catch Prisma unique-constraint conflicts from
creditLedger.create and return { created: false }; preserve existing behavior
for successful inserts and unrelated errors.

Comment on lines 114 to +116
} catch (error) {
if (error instanceof LockNotAcquiredError) {
return { created: false };

Copy link
Copy Markdown
Contributor

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

Do not report lock contention as an existing penalty.

LockNotAcquiredError only confirms that another request holds the lock. It does not confirm that the request created a ledger entry. If the lock owner fails before creditLedger.create, these branches return { created: false }. The caller then skips the email and does not retry allocation.

Return a distinct retryable lock result, or retry after the lock is released. Return the duplicate result only after confirming that the penalty exists.

As per coding guidelines, use discriminated unions for mutually exclusive data states.

Also applies to: 160-162

🤖 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 `@src/features/credits/utils/allocateCredits.ts` around lines 114 - 116, Update
the LockNotAcquiredError handling in allocateCredits to return a distinct
retryable lock-contention result rather than `{ created: false }`; apply the
same change to the corresponding branch near the later catch block. Model the
mutually exclusive outcomes with a discriminated union, and reserve the
duplicate result for cases where an existing penalty is confirmed.

Source: Coding guidelines

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 548aca64a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +115 to +116
if (error instanceof LockNotAcquiredError) {
return { created: false };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't treat lock contention as an existing penalty

When the Redis lock is not acquired, this returns { created: false }, and the submission label API treats that the same as “a penalty already exists” and returns success without queuing/creating anything. If the lock holder times out, crashes, or fails before the ledger insert, later attempts during the 5-minute TTL will leave the record marked Spam with no spam penalty; the same pattern exists in the grant penalty path. Please distinguish lock contention from an already-existing ledger entry, e.g. retry/check after the lock or surface a conflict so the operation can be retried.

Useful? React with 👍 / 👎.

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.

1 participant