-
Notifications
You must be signed in to change notification settings - Fork 0
feat: added limits for free users #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Call convex mutation, query | ||
| const conversation = await convex.query(api.system.getConversationById, { | ||
| internalKey, | ||
|
|
||
| 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 { | ||
|
|
@@ -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"; | ||
|
|
@@ -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); | ||
|
|
@@ -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); | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the draft when the send fails. Because Line 142 still clears 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 |
||
| } | ||
|
|
||
| setInput(""); | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't disable the stop action at the quota boundary. On the 10th free message, Proposed fix- disabled={atLimit || (isProcessing ? false : !input)}
+ disabled={isProcessing ? false : atLimit || !input}🤖 Prompt for AI Agents |
||
| status={isProcessing ? "streaming" : undefined} | ||
| /> | ||
| </PromptInputFooter> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Return a consistent billing window start.
The
!recordbranch returnsDate.now(), but the rest of the function treatsperiodStartas 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