Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,10 @@ export default defineSchema({
})
.index("by_conversation", ["conversationId"])
.index("by_project_status", ["projectId", "status"]),

usage: defineTable({
userId: v.string(),
messageCount: v.number(),
periodStart: v.number(), // Unix ms timestamp for start of current month
}).index("by_userId", ["userId"]),
});
69 changes: 69 additions & 0 deletions convex/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,75 @@ export const createProject = mutation({
},
});

export const checkAndIncrementUsage = mutation({
args: {
internalKey: v.string(),
userId: v.string(),
limit: v.number(),
},
handler: async (ctx, args) => {
validateInternalKey(args.internalKey);

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

const record = await ctx.db
.query("usage")
.withIndex("by_userId", (q) => q.eq("userId", args.userId))
.first();

if (!record) {
await ctx.db.insert("usage", {
userId: args.userId,
messageCount: 1,
periodStart,
});
return { allowed: true, count: 1 };
}

// New month — reset
if (record.periodStart < periodStart) {
await ctx.db.patch(record._id, { messageCount: 1, periodStart });
return { allowed: true, count: 1 };
}

// Over limit
if (record.messageCount >= args.limit) {
return { allowed: false, count: record.messageCount, limit: args.limit };
}

// Increment
await ctx.db.patch(record._id, { messageCount: record.messageCount + 1 });
return { allowed: true, count: record.messageCount + 1 };
},
});

export const getUserUsage = query({
args: {},
handler: async (ctx) => {
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);
Comment on lines +643 to +646
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.


// If record is from a prior month, return 0
if (record.periodStart < periodStart) {
return { messageCount: 0, periodStart };
}

return { messageCount: record.messageCount, periodStart: record.periodStart };
},
});

export const createProjectWithConversation = mutation({
args: {
internalKey: v.string(),
Expand Down
19 changes: 16 additions & 3 deletions src/app/api/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

if (!has({ plan: "pro" })) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
const FREE_MESSAGE_LIMIT = 10;
const hasPro = has({ plan: "pro" });

const internalKey = process.env.HEXSMITH_CONVEX_INTERNAL_KEY;

Expand All @@ -36,6 +35,20 @@ export async function POST(request: Request) {
const body = await request.json();
const { conversationId, message } = requestSchema.parse(body);

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 }
);
}
Comment on lines +38 to +49
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.

}

// Call convex mutation, query
const conversation = await convex.query(api.system.getConversationById, {
internalKey,
Expand Down
48 changes: 43 additions & 5 deletions src/features/conversations/components/conversation-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import ky from "ky";
import ky, { HTTPError } from "ky";
import { toast } from "sonner";
import { useState } from "react";
import { useQuery } from "convex/react";
import { useAuth, useClerk } from "@clerk/nextjs";
import { CopyIcon, HistoryIcon, LoaderIcon, PlusIcon } from "lucide-react";

import {
Expand Down Expand Up @@ -32,6 +34,7 @@ import {
useCreateConversation,
useMessages,
} from "../hooks/use-conversations";
import { api } from "../../../../convex/_generated/api";
import { Id } from "../../../../convex/_generated/dataModel";
import { DEFAULT_CONVERSATION_TITLE } from "../constants";
import { PastConversationsDialog } from "./past-conversations-dialog";
Expand All @@ -43,6 +46,8 @@ interface ConversationSidebarProps {
export const ConversationSidebar = ({
projectId,
}: ConversationSidebarProps) => {
const FREE_MESSAGE_LIMIT = 10;

const [input, setInput] = useState("");
const [selectedConversationId, setSelectedConversationId] =
useState<Id<"conversations"> | null>(null);
Expand All @@ -51,6 +56,13 @@ export const ConversationSidebar = ({
setPastConversationsOpen
] = useState(false);

const { has } = useAuth();
const { openUserProfile } = useClerk();
const hasPro = has ? has({ plan: "pro" }) : false;
const usage = useQuery(api.system.getUserUsage);
const messageCount = usage?.messageCount ?? 0;
const atLimit = !hasPro && messageCount >= FREE_MESSAGE_LIMIT;

const createConversation = useCreateConversation();
const conversations = useConversations(projectId);

Expand Down Expand Up @@ -114,8 +126,17 @@ export const ConversationSidebar = ({
message: message.text,
},
});
} catch {
toast.error("Message failed to send");
} 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");
}
Comment on lines +129 to +139
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.

}

setInput("");
Expand Down Expand Up @@ -185,19 +206,36 @@ export const ConversationSidebar = ({
<ConversationScrollButton />
</Conversation>
<div className="p-3">
{!hasPro && usage !== undefined && (
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
{atLimit ? (
<span className="text-destructive">
Message limit reached ({FREE_MESSAGE_LIMIT}/{FREE_MESSAGE_LIMIT})
</span>
) : (
<span>{FREE_MESSAGE_LIMIT - messageCount} messages left this month</span>
)}
<button
className="underline hover:text-foreground transition-colors"
onClick={() => openUserProfile()}
>
Upgrade →
</button>
</div>
)}
<PromptInput onSubmit={handleSubmit} className="mt-2">
<PromptInputBody>
<PromptInputTextarea
placeholder="Ask Hexsmith anything..."
onChange={(e) => setInput(e.target.value)}
value={input}
disabled={isProcessing}
disabled={isProcessing || atLimit}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools />
<PromptInputSubmit
disabled={isProcessing ? false : !input}
disabled={atLimit || (isProcessing ? false : !input)}
Comment on lines 237 to +238
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.

status={isProcessing ? "streaming" : undefined}
/>
</PromptInputFooter>
Expand Down