Skip to content

feat: added limits for free users#13

Merged
ryansoe merged 1 commit intomainfrom
feat/free-limits
Mar 7, 2026
Merged

feat: added limits for free users#13
ryansoe merged 1 commit intomainfrom
feat/free-limits

Conversation

@ryansoe
Copy link
Owner

@ryansoe ryansoe commented Mar 7, 2026

Summary by CodeRabbit

  • New Features
    • Introduced a 10-message monthly limit for free users
    • Users can now view their remaining free messages and current usage
    • Added an upgrade-to-Pro prompt when the monthly message limit is reached; Pro users remain unaffected

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

📝 Walkthrough

Walkthrough

Implements monthly message usage tracking for non-pro users by adding a usage database table, two new Convex endpoints (checkAndIncrementUsage mutation and getUserUsage query) to manage tracking, enforces a 10-message limit in the API layer, and updates the conversation UI to display usage status with an upgrade prompt.

Changes

Cohort / File(s) Summary
Database Schema
convex/schema.ts
Added usage table with userId, messageCount, periodStart fields and by_userId index to store per-user monthly message counts.
Backend Endpoints
convex/system.ts
Added checkAndIncrementUsage mutation to validate internal key, track monthly usage, reset at month boundaries, and enforce limits; added getUserUsage query to retrieve current user's message count and period start with fallback to zero values.
API Rate Limiting
src/app/api/messages/route.ts
Integrated free message limit enforcement (10 messages) for non-pro users by checking usage via checkAndIncrementUsage and returning HTTP 429 when limit is exceeded; pro users bypass the check.
Frontend Usage Display
src/features/conversations/components/conversation-sidebar.tsx
Added usage data fetching via useQuery, conditional message limit banner showing remaining/exhausted messages, upgrade prompt on 429 errors, and disabled input/submit button states when limit is reached.

Sequence Diagram

sequenceDiagram
    actor User
    participant Frontend as Client/UI
    participant API as API Route
    participant Convex as Convex System
    participant DB as Database

    rect rgba(100, 150, 200, 0.5)
    Note over User,DB: Message Submission Flow
    User->>Frontend: Submit message
    Frontend->>API: POST /api/messages
    
    rect rgba(150, 100, 200, 0.5)
    Note over API,DB: Usage Check (Non-Pro)
    API->>Convex: checkAndIncrementUsage(userId, limit)
    Convex->>DB: Query usage by userId
    DB-->>Convex: Return usage record
    
    alt Current month
        Convex->>Convex: Validate count < limit
        alt Allowed
            Convex->>DB: Increment messageCount
            Convex-->>API: { allowed: true, count }
        else Limit reached
            Convex-->>API: { allowed: false, count, limit }
            API-->>Frontend: 429 Too Many Requests
        end
    else New month
        Convex->>DB: Reset usage (new period)
        Convex->>DB: Increment messageCount
        Convex-->>API: { allowed: true, count }
    end
    end
    
    rect rgba(200, 150, 100, 0.5)
    Note over Frontend,DB: Render Usage State
    Frontend->>Convex: getUserUsage()
    Convex->>DB: Query usage by userId
    DB-->>Convex: Return messageCount, periodStart
    Convex-->>Frontend: Usage data
    Frontend->>Frontend: Display banner/disable input
    end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~28 minutes

Poem

🐰 A hop, a check, a count so keen,
Ten messages per month, a generous scene!
But upgrade your wings to soar more high,
And watch your limits reach the sky! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the pull request—implementing usage limits for free users across the application by adding monthly message tracking.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/free-limits

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convex/system.ts`:
- Around line 643-646: The early-return for the missing `record` currently
returns periodStart = Date.now(), which is inconsistent with the later computed
`periodStart` (start of current month); change the `!record` branch to compute
the same month-start timestamp as the main path (use new Date() and
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) or reuse the same
`periodStart` calculation) so the returned `{ messageCount: 0, periodStart }`
uses the same month-start semantics as the rest of the function.

In `@src/app/api/messages/route.ts`:
- Around line 38-49: The current code calls
convex.mutation(api.system.checkAndIncrementUsage, ...) before validating the
conversation and before writing the message, which can consume quota on
failures; move the quota check/increment (api.system.checkAndIncrementUsage) to
after the conversation validation (the conversation lookup/validation logic in
this route) and before the message write so you only consume quota for a
validated send. Better: implement a single Convex mutation that performs
conversation validation, usage check-and-increment, and the message creation in
one atomic server-side operation (fold api.system.checkAndIncrementUsage + the
conversation validation + the message creation mutation into one mutation) so
all three succeed or fail together.

In `@src/features/conversations/components/conversation-sidebar.tsx`:
- Around line 237-238: The submit button currently disables when atLimit is true
even if an operation is in progress, preventing the stop/cancel path in
handleSubmit; update the PromptInputSubmit disabled prop so the button is
enabled whenever isProcessing is true, e.g. replace the expression
disabled={atLimit || (isProcessing ? false : !input)} with a condition that only
disables when not processing: disabled={!isProcessing && (atLimit || !input)},
ensuring stop/cancel remains clickable during processing.
- Around line 129-139: The catch block in conversation-sidebar.tsx is clearing
the input after failures; modify the send handler (the function that catches
errors) so it does not clear the input state on error — move the input-clearing
logic to the success path only, and add an early return inside the catch branch
(for both the 429 HTTPError case and the generic error case) so the draft is
preserved; use the existing identifiers (the catch block, toast.error calls,
openUserProfile, and the input state variable) to locate and update the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d733b8f-ae08-493a-a23e-53e3a07bbc72

📥 Commits

Reviewing files that changed from the base of the PR and between 33c4aea and a39c127.

📒 Files selected for processing (4)
  • convex/schema.ts
  • convex/system.ts
  • src/app/api/messages/route.ts
  • src/features/conversations/components/conversation-sidebar.tsx

Comment on lines +643 to +646
if (!record) return { messageCount: 0, periodStart: Date.now() };

const now = new Date();
const periodStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Return a consistent billing window start.

The !record branch returns Date.now(), but the rest of the function treats periodStart as the start of the current month. That makes the same field mean two different things.

Proposed fix
 export const getUserUsage = query({
   args: {},
   handler: async (ctx) => {
+    const now = new Date();
+    const periodStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
+
     const identity = await ctx.auth.getUserIdentity();
     if (!identity) return null;
 
     const userId = identity.subject;
     const record = await ctx.db
       .query("usage")
       .withIndex("by_userId", (q) => q.eq("userId", userId))
       .first();
 
-    if (!record) return { messageCount: 0, periodStart: Date.now() };
-
-    const now = new Date();
-    const periodStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1);
+    if (!record) return { messageCount: 0, periodStart };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convex/system.ts` around lines 643 - 646, The early-return for the missing
`record` currently returns periodStart = Date.now(), which is inconsistent with
the later computed `periodStart` (start of current month); change the `!record`
branch to compute the same month-start timestamp as the main path (use new
Date() and Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) or reuse the
same `periodStart` calculation) so the returned `{ messageCount: 0, periodStart
}` uses the same month-start semantics as the rest of the function.

Comment on lines +38 to +49
if (!hasPro) {
const usage = await convex.mutation(api.system.checkAndIncrementUsage, {
internalKey,
userId,
limit: FREE_MESSAGE_LIMIT,
});
if (!usage.allowed) {
return NextResponse.json(
{ error: "Free message limit reached", count: usage.count, limit: FREE_MESSAGE_LIMIT },
{ status: 429 }
);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't burn quota before the send can actually proceed.

This increments usage before the conversation lookup on Lines 53-63 and before any message write. A bad conversationId or later failure will still consume one of the user's monthly messages. At minimum, move the increment after validation; ideally, fold validation, quota accounting, and message creation into a single Convex mutation so they commit together.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/messages/route.ts` around lines 38 - 49, The current code calls
convex.mutation(api.system.checkAndIncrementUsage, ...) before validating the
conversation and before writing the message, which can consume quota on
failures; move the quota check/increment (api.system.checkAndIncrementUsage) to
after the conversation validation (the conversation lookup/validation logic in
this route) and before the message write so you only consume quota for a
validated send. Better: implement a single Convex mutation that performs
conversation validation, usage check-and-increment, and the message creation in
one atomic server-side operation (fold api.system.checkAndIncrementUsage + the
conversation validation + the message creation mutation into one mutation) so
all three succeed or fail together.

Comment on lines +129 to +139
} catch (error) {
if (error instanceof HTTPError && error.response.status === 429) {
toast.error("You've used all 10 free messages this month.", {
action: {
label: "Upgrade to Pro",
onClick: () => openUserProfile(),
},
});
} else {
toast.error("Message failed to send");
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the draft when the send fails.

Because Line 142 still clears input after this catch, a 429 or transient failure discards the user's prompt even though nothing was sent. Clear the input only on success, and return from the catch path.

Proposed fix
     try {
       await ky.post("/api/messages", {
         json: {
           conversationId,
           message: message.text,
         },
       });
+      setInput("");
     } catch (error) {
       if (error instanceof HTTPError && error.response.status === 429) {
         toast.error("You've used all 10 free messages this month.", {
           action: {
             label: "Upgrade to Pro",
             onClick: () => openUserProfile(),
           },
         });
       } else {
         toast.error("Message failed to send");
       }
+      return;
     }
-
-    setInput("");
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/conversations/components/conversation-sidebar.tsx` around lines
129 - 139, The catch block in conversation-sidebar.tsx is clearing the input
after failures; modify the send handler (the function that catches errors) so it
does not clear the input state on error — move the input-clearing logic to the
success path only, and add an early return inside the catch branch (for both the
429 HTTPError case and the generic error case) so the draft is preserved; use
the existing identifiers (the catch block, toast.error calls, openUserProfile,
and the input state variable) to locate and update the code.

Comment on lines 237 to +238
<PromptInputSubmit
disabled={isProcessing ? false : !input}
disabled={atLimit || (isProcessing ? false : !input)}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't disable the stop action at the quota boundary.

On the 10th free message, isProcessing can be true while atLimit is also true. This expression disables submit in that state, so the user can't trigger the stop/cancel path in handleSubmit.

Proposed fix
-              disabled={atLimit || (isProcessing ? false : !input)}
+              disabled={isProcessing ? false : atLimit || !input}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/conversations/components/conversation-sidebar.tsx` around lines
237 - 238, The submit button currently disables when atLimit is true even if an
operation is in progress, preventing the stop/cancel path in handleSubmit;
update the PromptInputSubmit disabled prop so the button is enabled whenever
isProcessing is true, e.g. replace the expression disabled={atLimit ||
(isProcessing ? false : !input)} with a condition that only disables when not
processing: disabled={!isProcessing && (atLimit || !input)}, ensuring
stop/cancel remains clickable during processing.

@ryansoe ryansoe merged commit 91bbb26 into main Mar 7, 2026
7 checks passed
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