diff --git a/convex/schema.ts b/convex/schema.ts index d6b9007..2b47c38 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]), }); diff --git a/convex/system.ts b/convex/system.ts index 2153ffd..22c9a84 100644 --- a/convex/system.ts +++ b/convex/system.ts @@ -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); + + // 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(), diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts index ba78c00..d0d62ac 100644 --- a/src/app/api/messages/route.ts +++ b/src/app/api/messages/route.ts @@ -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 } + ); + } + } + // Call convex mutation, query const conversation = await convex.query(api.system.getConversationById, { internalKey, diff --git a/src/features/conversations/components/conversation-sidebar.tsx b/src/features/conversations/components/conversation-sidebar.tsx index 260a2d8..5314d3a 100644 --- a/src/features/conversations/components/conversation-sidebar.tsx +++ b/src/features/conversations/components/conversation-sidebar.tsx @@ -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 | 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"); + } } setInput(""); @@ -185,19 +206,36 @@ export const ConversationSidebar = ({
+ {!hasPro && usage !== undefined && ( +
+ {atLimit ? ( + + Message limit reached ({FREE_MESSAGE_LIMIT}/{FREE_MESSAGE_LIMIT}) + + ) : ( + {FREE_MESSAGE_LIMIT - messageCount} messages left this month + )} + +
+ )} setInput(e.target.value)} value={input} - disabled={isProcessing} + disabled={isProcessing || atLimit} />