Skip to content

Fixes credits and submissions race condition#1411

Open
RevTpark wants to merge 1 commit into
mainfrom
fix/credits-race-condition
Open

Fixes credits and submissions race condition#1411
RevTpark wants to merge 1 commit into
mainfrom
fix/credits-race-condition

Conversation

@RevTpark

@RevTpark RevTpark commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

  • add idempotencyKey and dedupeKey to properly identify credits and submissions
  • changed prisma commit to sql transaction in order to avoid conflicting commits

Where should the reviewer start?

How should this be manually tested?

Any background context you want to provide?

What are the relevant issues?

Screenshots (if appropriate)

Summary by CodeRabbit

  • New Features

    • Added deduplication and idempotency checks to submission creation and credit consumption for improved reliability.
  • Bug Fixes

    • Enhanced error handling for concurrent requests to prevent duplicate submissions and ensure credits are charged only once.

@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
earn Ready Ready Preview Jun 18, 2026 12:48pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds dedupeKey and idempotencyKey unique fields to the Prisma schema, then threads optional injectable Prisma clients through creditAggregate, canUserSubmit, and consumeCredit. Updates createSubmission to generate a dedupe key, wrap submission creation and credit consumption in a single prisma.$transaction with a SELECT ... FOR UPDATE user lock, and map P2002 unique-constraint errors to idempotent early returns.

Changes

Idempotent Submission and Credit Transaction

Layer / File(s) Summary
Schema: dedupeKey and idempotencyKey fields
prisma/schema.prisma
Adds optional unique dedupeKey (VarChar(191)) to Submission and optional unique idempotencyKey (VarChar(191)) to CreditLedger.
Credit utility dependency injection chain
src/features/credits/utils/creditAggregate.ts, src/features/credits/utils/canUserSubmit.ts, src/features/credits/utils/allocateCredits.ts
Adds optional client parameters to creditAggregate, canUserSubmit, and consumeCredit so all three can run against a transaction client. consumeCredit additionally embeds idempotencyKey in the ledger insert and silences P2002 unique-constraint errors.
Transactional submission creation with dedupe
src/pages/api/submission/create.ts
Adds submissionDedupeKey, injectable options.client, and a prisma.$transaction block that row-locks the user, checks credits, creates the submission with dedupeKey, and consumes the credit atomically; maps P2002 violations to "Submission already exists".

Sequence Diagram(s)

sequenceDiagram
  participant Handler as API Handler
  participant TX as prisma.$transaction
  participant DB as Database
  participant canUserSubmit
  participant createSubmission
  participant consumeCredit

  Handler->>TX: begin transaction
  TX->>DB: SELECT user WHERE id=userId FOR UPDATE
  TX->>createSubmission: createSubmission(userId, listingId, data, listing, {client: txClient})
  createSubmission->>canUserSubmit: canUserSubmit(userId, txClient)
  canUserSubmit->>DB: creditLedger.aggregate + user.findUnique (via txClient)
  DB-->>canUserSubmit: balance + user plan
  canUserSubmit-->>createSubmission: hasCredits (boolean)
  createSubmission->>DB: client.submission.create(dedupeKey, ...)
  DB-->>createSubmission: submission | P2002 → "Submission already exists"
  createSubmission->>consumeCredit: consumeCredit(userId, submissionId, txClient)
  consumeCredit->>DB: creditLedger.create(idempotencyKey, ...)
  DB-->>consumeCredit: ledger entry | P2002 (swallowed)
  TX-->>Handler: commit → submission
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • SuperteamDAO/earn#1410: Modifies the same createSubmission handler in src/pages/api/submission/create.ts, adding sanitization for otherInfo and eligibilityAnswers fields.

Suggested reviewers

  • a20hek

Poem

🐇 Hoppity-hop through the database rows,
A dedupe key planted wherever code goes!
Transactions lock tight with a FOR UPDATE flair,
P2002? No panic — just breathe the fresh air.
Idempotent credits, no doubles allowed,
The rabbit submits once, and hops off — so proud! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fixes credits and submissions race condition' directly and clearly summarizes the main objective of the changeset, which adds idempotency/deduplication keys and implements transactional handling to prevent race conditions in credit and submission processing.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/credits-race-condition

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 and usage tips.

@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: 7

🤖 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 11-15: The consumeCredit function is missing an explicit return
type annotation. Add a return type annotation of Promise<void> to the function
signature after the closing parenthesis of the parameters list and before the
opening curly brace. This ensures the function complies with the requirement
that top-level module functions in TypeScript have explicit return types.
- Around line 6-7: The currentMonth and nextMonth variables are computed at
module load time on lines 6-7, which causes them to become stale if the module
remains loaded across a month boundary. Move these variable declarations from
the module scope into the consumeCredit function itself, so they are
recalculated on each invocation to ensure they always represent the current and
next month at call time. Additionally, add an explicit return type annotation of
Promise<void> to the consumeCredit function signature on line 11 to comply with
the explicit return type requirement for top-level module functions.
- Around line 29-32: The P2002 error suppression in the allocateCredits function
is too broad and masks all unique constraint violations, including unrelated
foreign key constraint failures. Instead of returning whenever error.code ===
'P2002', narrow the condition to check the error's metadata field to verify it's
specifically the idempotency constraint violation before suppressing it. Only
return early if the constraint name matches the idempotency constraint fields
(typically a combination of userId, submissionId, and applicationId), allowing
other P2002 errors to propagate and be handled appropriately.

In `@src/features/credits/utils/canUserSubmit.ts`:
- Around line 7-10: The function `canUserSubmit` is missing an explicit return
type annotation. Add `Promise<boolean>` as the return type annotation to the
function signature to comply with coding guidelines that require top-level
module functions to declare their return types explicitly rather than relying on
TypeScript's type inference.

In `@src/features/credits/utils/creditAggregate.ts`:
- Around line 6-9: The creditAggregate function is missing an explicit return
type annotation. Add the return type Promise<number> to the function signature
of creditAggregate since it returns a numeric value representing the effective
balance. This aligns with the requirement that top-level exported functions in
TypeScript must declare their return types explicitly.

In `@src/pages/api/submission/create.ts`:
- Around line 162-188: The generic Error thrown for insufficient credits in the
prisma transaction (the throw new Error statement within the canUserSubmit
check) gets caught by the existing catch block and misclassified as a 403 error.
Instead of throwing a generic Error, create a custom error class or use an error
with a specific type/code that can be distinguished from unexpected database
errors in the outer catch handler. This way, the insufficient credits scenario
can remain a 403, while genuine database failures and other unexpected errors in
the transaction flow are properly returned as 500 errors to maintain correct API
error semantics.
- Around line 24-34: The functions `submissionDedupeKey` and `createSubmission`
are top-level module functions that lack explicit return type annotations, which
violates the coding guidelines. Add an explicit return type annotation of `:
string` to the `submissionDedupeKey` function signature, and add the appropriate
return type annotation to the `createSubmission` function signature based on
what it actually returns. These annotations should be placed between the closing
parenthesis of the parameter list and the opening curly brace of each function.
🪄 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: d6f867a8-3f3c-4650-b0b1-e1206bc96ab3

📥 Commits

Reviewing files that changed from the base of the PR and between 932ef93 and 73654c0.

📒 Files selected for processing (5)
  • prisma/schema.prisma
  • src/features/credits/utils/allocateCredits.ts
  • src/features/credits/utils/canUserSubmit.ts
  • src/features/credits/utils/creditAggregate.ts
  • src/pages/api/submission/create.ts

Comment on lines 6 to 7
const currentMonth = dayjs.utc().startOf('month').toDate();
const nextMonth = dayjs.utc().add(1, 'month').startOf('month').toDate();

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/features/credits/utils/allocateCredits.ts

Repository: SuperteamDAO/earn

Length of output: 9732


Compute currentMonth at call time, not module load time.

Line 6 freezes currentMonth when the process starts. If the module stays loaded across a month boundary, subsequent calls to consumeCredit will write debits to the previous month's effectiveMonth, corrupting monthly balance accounting.

🛠️ Suggested fix
-const currentMonth = dayjs.utc().startOf('month').toDate();
-const nextMonth = dayjs.utc().add(1, 'month').startOf('month').toDate();
+function getCurrentMonth(): Date {
+  return dayjs.utc().startOf('month').toDate();
+}
+
+function getNextMonth(): Date {
+  return dayjs.utc().add(1, 'month').startOf('month').toDate();
+}
@@
-        effectiveMonth: currentMonth,
+        effectiveMonth: getCurrentMonth(),

Also add explicit return type Promise<void> to consumeCredit (line 11) to comply with the guideline requiring explicit return types for top-level module functions.

🤖 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 6 - 7, The
currentMonth and nextMonth variables are computed at module load time on lines
6-7, which causes them to become stale if the module remains loaded across a
month boundary. Move these variable declarations from the module scope into the
consumeCredit function itself, so they are recalculated on each invocation to
ensure they always represent the current and next month at call time.
Additionally, add an explicit return type annotation of Promise<void> to the
consumeCredit function signature on line 11 to comply with the explicit return
type requirement for top-level module functions.

Comment on lines +11 to +15
export async function consumeCredit(
userId: string,
submissionId: string,
client: PrismaLike = prisma,
) {

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "allocateCredits.ts" -type f

Repository: SuperteamDAO/earn

Length of output: 109


🏁 Script executed:

cat -n ./src/features/credits/utils/allocateCredits.ts | head -30

Repository: SuperteamDAO/earn

Length of output: 1198


🏁 Script executed:

cat -n ./src/features/credits/utils/allocateCredits.ts | sed -n '11,50p'

Repository: SuperteamDAO/earn

Length of output: 1247


Add an explicit return type on consumeCredit.

The function lacks an explicit return annotation. Declare Promise<void> to comply with the requirement that top-level module functions in TypeScript have explicit return types.

🤖 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 11 - 15, The
consumeCredit function is missing an explicit return type annotation. Add a
return type annotation of Promise<void> to the function signature after the
closing parenthesis of the parameters list and before the opening curly brace.
This ensures the function complies with the requirement that top-level module
functions in TypeScript have explicit return types.

Source: Coding guidelines

Comment on lines +29 to +32
error instanceof PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return;

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/features/credits/utils/allocateCredits.ts

Repository: SuperteamDAO/earn

Length of output: 9732


🏁 Script executed:

fd -t f schema.prisma

Repository: SuperteamDAO/earn

Length of output: 82


🏁 Script executed:

cat prisma/schema.prisma | grep -A 50 "model CreditLedger"

Repository: SuperteamDAO/earn

Length of output: 1569


🏁 Script executed:

rg "P2002|meta\.target" --type ts --type tsx -B 3 -A 3

Repository: SuperteamDAO/earn

Length of output: 89


🏁 Script executed:

rg "P2002|meta\.target" -B 3 -A 3

Repository: SuperteamDAO/earn

Length of output: 2512


🏁 Script executed:

cat prisma/schema.prisma | grep -B 5 -A 30 "model CreditLedger"

Repository: SuperteamDAO/earn

Length of output: 1207


Narrow P2002 suppression to the idempotency constraint only.

The current code suppresses all P2002 errors, which masks unrelated unique constraint violations. This can silently skip a credit debit if a foreign key constraint fails (e.g., invalid userId, submissionId, or applicationId), causing data integrity issues.

🛠️ Suggested fix
-    if (
-      error instanceof PrismaClientKnownRequestError &&
-      error.code === 'P2002'
-    ) {
+    if (
+      error instanceof PrismaClientKnownRequestError &&
+      error.code === 'P2002' &&
+      (
+        (Array.isArray(error.meta?.target) &&
+          error.meta.target.includes('idempotencyKey')) ||
+        error.meta?.target === 'idempotencyKey'
+      )
+    ) {
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
error instanceof PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return;
if (
error instanceof PrismaClientKnownRequestError &&
error.code === 'P2002' &&
(
(Array.isArray(error.meta?.target) &&
error.meta.target.includes('idempotencyKey')) ||
error.meta?.target === 'idempotencyKey'
)
) {
return;
}
🤖 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 29 - 32, The
P2002 error suppression in the allocateCredits function is too broad and masks
all unique constraint violations, including unrelated foreign key constraint
failures. Instead of returning whenever error.code === 'P2002', narrow the
condition to check the error's metadata field to verify it's specifically the
idempotency constraint violation before suppressing it. Only return early if the
constraint name matches the idempotency constraint fields (typically a
combination of userId, submissionId, and applicationId), allowing other P2002
errors to propagate and be handled appropriately.

Comment on lines +7 to +10
export async function canUserSubmit(
userId: string,
client: PrismaLike = prisma,
) {

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/features/credits/utils/canUserSubmit.ts

Repository: SuperteamDAO/earn

Length of output: 494


Add return type annotation Promise<boolean> to canUserSubmit.

The function currently relies on TypeScript's type inference. Top-level module functions must declare their return types explicitly per coding guidelines.

🤖 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/canUserSubmit.ts` around lines 7 - 10, The
function `canUserSubmit` is missing an explicit return type annotation. Add
`Promise<boolean>` as the return type annotation to the function signature to
comply with coding guidelines that require top-level module functions to declare
their return types explicitly rather than relying on TypeScript's type
inference.

Source: Coding guidelines

Comment on lines +6 to +9
export async function creditAggregate(
userId: string,
client: PrismaLike = prisma,
) {

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/features/credits/utils/creditAggregate.ts

Repository: SuperteamDAO/earn

Length of output: 1006


Add explicit return type annotation to the exported creditAggregate function.

The function is missing a return type declaration. Since it returns a numeric value (effectiveBalance), add Promise<number> to the function signature:

export async function creditAggregate(
  userId: string,
  client: PrismaLike = prisma,
): Promise<number> {

Per coding guidelines, top-level module functions in TypeScript must declare return types.

🤖 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/creditAggregate.ts` around lines 6 - 9, The
creditAggregate function is missing an explicit return type annotation. Add the
return type Promise<number> to the function signature of creditAggregate since
it returns a numeric value representing the effective balance. This aligns with
the requirement that top-level exported functions in TypeScript must declare
their return types explicitly.

Source: Coding guidelines

Comment on lines +24 to +34
function submissionDedupeKey(
userId: string,
listingId: string,
options?: { isAgent?: boolean; agentId?: string },
) {
if (options?.isAgent && options.agentId) {
return `agent:${options.agentId}:listing:${listingId}`;
}

return `user:${userId}:listing:${listingId}`;
}

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/pages/api/submission/create.ts | head -50

Repository: SuperteamDAO/earn

Length of output: 2197


🏁 Script executed:

cat -n src/pages/api/submission/create.ts | sed -n '36,150p'

Repository: SuperteamDAO/earn

Length of output: 3868


Add explicit return type annotations to top-level module functions.

submissionDedupeKey (line 24) and createSubmission (line 36) are top-level module functions without explicit return type annotations. Annotate submissionDedupeKey with : string and createSubmission with its appropriate return type.

Per the coding guidelines: "Declare return types for top-level module functions in TypeScript (exception: React components returning JSX)".

🤖 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/pages/api/submission/create.ts` around lines 24 - 34, The functions
`submissionDedupeKey` and `createSubmission` are top-level module functions that
lack explicit return type annotations, which violates the coding guidelines. Add
an explicit return type annotation of `: string` to the `submissionDedupeKey`
function signature, and add the appropriate return type annotation to the
`createSubmission` function signature based on what it actually returns. These
annotations should be placed between the closing parenthesis of the parameter
list and the opening curly brace of each function.

Source: Coding guidelines

Comment on lines +162 to +188
const result =
!isHackathon && !isPro
? await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT id FROM \`User\`
WHERE id = ${userId as string}
FOR UPDATE
`;

const hasCredits = await canUserSubmit(userId as string, tx);
if (!hasCredits) {
throw new Error('Insufficient credits');
}

const submission = await createSubmission(
userId as string,
listingId,
{ link, tweet, otherInfo, eligibilityAnswers, ask, telegram },
listing,
{ client: tx },
);

await consumeCredit(userId, submission.id, tx);
logger.info(`Consumed 1 credit from user ${userId} for submission`);

return submission;
})

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid generic throws in the transaction flow; they get misclassified as 403.

Line 173 introduces a generic Error, and this new transactional path can also surface unexpected DB errors. With the existing catch behavior, those failures are returned as 403 instead of 500, which breaks API error semantics and incident visibility.

As per coding guidelines, "Use HTTP status codes: ... 500 for internal server error".

🤖 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/pages/api/submission/create.ts` around lines 162 - 188, The generic Error
thrown for insufficient credits in the prisma transaction (the throw new Error
statement within the canUserSubmit check) gets caught by the existing catch
block and misclassified as a 403 error. Instead of throwing a generic Error,
create a custom error class or use an error with a specific type/code that can
be distinguished from unexpected database errors in the outer catch handler.
This way, the insufficient credits scenario can remain a 403, while genuine
database failures and other unexpected errors in the transaction flow are
properly returned as 500 errors to maintain correct API error semantics.

Source: Coding guidelines

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