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
7 changes: 7 additions & 0 deletions apps/product-helper/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ lib/
- **Tests:** Co-located `__tests__/` directories next to source files
- **LLM provider:** Anthropic Claude via `@langchain/anthropic` (not OpenAI)

### Credit System (`lib/db/queries.ts`)
- `checkAndDeductCredits(teamId, amount)` β€” atomic check-and-deduct with race-safe WHERE clause
- Free tier: 2,500 credits (Quick Start=1250, chat=5, regen=100)
- Paid tier: 999,999 credits (effectively unlimited)
- Credits reset on subscription change (active→0/999999, canceled→0/2500)
- 402 responses handled in Quick Start dialog (upgrade prompt) and chat (toast with upgrade link)

## Active Work

**Credit System:** Deployed (2026-02-19)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type { APISpecGenerationContext } from '@/lib/types/api-specification';
import { generateInfrastructureSpec, type InfrastructureContext } from '@/lib/langchain/agents/infrastructure-agent';
import { generateCodingGuidelines, type GuidelinesContext } from '@/lib/langchain/agents/guidelines-agent';
import { userStories } from '@/lib/db/schema';
import type { KBProjectContext } from '@/lib/education/reference-data/types';

// ============================================================
// Types
Expand Down Expand Up @@ -755,6 +756,42 @@ async function triggerPostIntakeGeneration(
): Promise<string> {
console.log(`[POST_INTAKE] Starting post-intake generation for project ${projectId}`);

// Query project onboarding metadata and map to KBProjectContext
let projectContext: Partial<KBProjectContext> = {};
try {
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
columns: { projectType: true, projectStage: true, budget: true },
});
if (project) {
const typeMap: Record<string, KBProjectContext['projectType']> = {
saas: 'saas', marketplace: 'marketplace', mobile: 'mobile',
'api-platform': 'api-platform', 'ai-product': 'ai-product',
'e-commerce': 'e-commerce', 'internal-tool': 'internal-tool',
'open-source': 'open-source',
};
const stageMap: Record<string, KBProjectContext['stage']> = {
idea: 'idea', prototype: 'mvp', mvp: 'mvp', growth: 'growth', mature: 'mature',
};
const budgetMap: Record<string, KBProjectContext['budget']> = {
'bootstrap': 'bootstrap', 'seed': 'seed', 'series-a': 'series-a',
'enterprise': 'enterprise', '$0-$100': 'bootstrap', '$100-$1K': 'seed',
'$1K-$10K': 'series-a', '$10K+': 'enterprise',
};
if (project.projectType && typeMap[project.projectType]) {
projectContext.projectType = typeMap[project.projectType];
}
if (project.projectStage && stageMap[project.projectStage]) {
projectContext.stage = stageMap[project.projectStage];
}
if (project.budget && budgetMap[project.budget]) {
projectContext.budget = budgetMap[project.budget];
}
}
} catch (error) {
console.warn('[POST_INTAKE] Failed to load project context, using generic:', error);
}

const actors = extractedData.actors ?? [];
const useCases = extractedData.useCases ?? [];
let dataEntities = extractedData.dataEntities ?? [];
Expand Down Expand Up @@ -1005,6 +1042,7 @@ async function triggerPostIntakeGeneration(
projectVision: enrichedVision,
useCases: useCases.map((uc, i) => ({ name: uc.name, description: enrichedUseCaseDescriptions[i] })),
dataEntities: dataEntities.map(e => ({ name: e.name })),
projectContext,
};

const userStoriesCtx: UserStoriesContext = {
Expand All @@ -1025,6 +1063,7 @@ async function triggerPostIntakeGeneration(
name: a.name,
role: a.role,
})),
projectContext,
};

const schemaCtx: SchemaExtractionContext = {
Expand All @@ -1036,6 +1075,7 @@ async function triggerPostIntakeGeneration(
relationships: e.relationships ?? [],
})),
useCases: useCases.map((uc, i) => ({ name: uc.name, description: enrichedUseCaseDescriptions[i] })),
projectContext,
};

const apiSpecCtx: APISpecGenerationContext = {
Expand All @@ -1054,11 +1094,13 @@ async function triggerPostIntakeGeneration(
attributes: e.attributes ?? [],
relationships: e.relationships ?? [],
})),
projectContext,
};

const infraCtx: InfrastructureContext = {
projectName,
projectDescription: enrichedVision,
projectContext,
};

// Phase 1: Run 5 generators in parallel
Expand All @@ -1079,6 +1121,7 @@ async function triggerPostIntakeGeneration(
const guidelinesCtx: GuidelinesContext = {
projectName,
techStack: techStackResult.value,
projectContext,
};
[guidelinesResult] = await Promise.allSettled([generateCodingGuidelines(guidelinesCtx)]);
} else {
Expand Down
14 changes: 14 additions & 0 deletions apps/product-helper/app/api/chat/projects/[projectId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ export async function POST(
);
}

// Credit check
const creditResult = await checkAndDeductCredits(team.id, 5);
if (!creditResult.allowed) {
return new Response(
JSON.stringify({
error: 'credit_limit_reached',
message: 'You\'ve used all your free credits. Upgrade to continue.',
creditsUsed: creditResult.creditsUsed,
creditLimit: creditResult.creditLimit,
}),
{ status: 402, headers: { 'Content-Type': 'application/json' } }
);
}

// Rate limit check: 100 requests per minute per user (uses shared rate-limit config)
const rateLimitKey = `chat-user-${user.id}`;
const rateLimitResult = checkRateLimit(rateLimitKey);
Expand Down
10 changes: 10 additions & 0 deletions apps/product-helper/app/api/projects/[id]/api-spec/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { withProjectAuth } from '@/lib/api/with-project-auth';
import { checkAndDeductCredits } from '@/lib/db/queries';
import { db } from '@/lib/db/drizzle';
import { projects, projectData } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
Expand Down Expand Up @@ -99,6 +100,15 @@ export const GET = withProjectAuth(
*/
export const POST = withProjectAuth(
async (req, { team, projectId }) => {
// Credit check
const creditResult = await checkAndDeductCredits(team.id, 100);
if (!creditResult.allowed) {
return NextResponse.json(
{ error: 'credit_limit_reached', creditsUsed: creditResult.creditsUsed, creditLimit: creditResult.creditLimit },
{ status: 402 }
);
}

// Fetch project with projectData
const project = await db.query.projects.findFirst({
where: and(
Expand Down
10 changes: 10 additions & 0 deletions apps/product-helper/app/api/projects/[id]/guidelines/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { withProjectAuth } from '@/lib/api/with-project-auth';
import { checkAndDeductCredits } from '@/lib/db/queries';
import { db } from '@/lib/db/drizzle';
import { projects, projectData } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
Expand Down Expand Up @@ -60,6 +61,15 @@ export const GET = withProjectAuth(
*/
export const POST = withProjectAuth(
async (req, { team, projectId }) => {
// Credit check
const creditResult = await checkAndDeductCredits(team.id, 100);
if (!creditResult.allowed) {
return NextResponse.json(
{ error: 'credit_limit_reached', creditsUsed: creditResult.creditsUsed, creditLimit: creditResult.creditLimit },
{ status: 402 }
);
}

// Verify project exists and belongs to team
const project = await db.query.projects.findFirst({
where: and(eq(projects.id, projectId), eq(projects.teamId, team.id)),
Expand Down
10 changes: 10 additions & 0 deletions apps/product-helper/app/api/projects/[id]/infrastructure/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { withProjectAuth } from '@/lib/api/with-project-auth';
import { checkAndDeductCredits } from '@/lib/db/queries';
import { db } from '@/lib/db/drizzle';
import { projects, projectData } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
Expand Down Expand Up @@ -66,6 +67,15 @@ export const GET = withProjectAuth(
*/
export const POST = withProjectAuth(
async (req, { team, projectId }) => {
// Credit check
const creditResult = await checkAndDeductCredits(team.id, 100);
if (!creditResult.allowed) {
return NextResponse.json(
{ error: 'credit_limit_reached', creditsUsed: creditResult.creditsUsed, creditLimit: creditResult.creditLimit },
{ status: 402 }
);
}

// Verify project exists and belongs to team
const project = await db.query.projects.findFirst({
where: and(
Expand Down
10 changes: 10 additions & 0 deletions apps/product-helper/app/api/projects/[id]/quick-start/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withProjectAuth } from '@/lib/api/with-project-auth';
import { checkAndDeductCredits } from '@/lib/db/queries';
import { db } from '@/lib/db/drizzle';
import { projects } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
Expand Down Expand Up @@ -60,6 +61,15 @@ export const POST = withProjectAuth(
);
}

// Credit check β€” before any AI generation
const creditResult = await checkAndDeductCredits(team.id, 1250);
if (!creditResult.allowed) {
return NextResponse.json(
{ error: 'credit_limit_reached', creditsUsed: creditResult.creditsUsed, creditLimit: creditResult.creditLimit },
{ status: 402 }
);
}

// Validate input
let body;
try {
Expand Down
9 changes: 3 additions & 6 deletions apps/product-helper/app/api/projects/[id]/stories/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { withProjectAuth } from '@/lib/api/with-project-auth';
import { checkAndDeductCredits } from '@/lib/db/queries';
import { db } from '@/lib/db/drizzle';
import { projects, userStories } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
Expand Down Expand Up @@ -92,15 +93,11 @@ export const POST = withProjectAuth(

// Option 1: Generate stories from use cases
if (body.generate === true) {
// Credit gate: Story generation costs 100 credits
// Credit check β€” only for AI generation, not manual story creation
const creditResult = await checkAndDeductCredits(team.id, 100);
if (!creditResult.allowed) {
return NextResponse.json(
{
error: 'Credit limit reached',
creditsUsed: creditResult.creditsUsed,
creditLimit: creditResult.creditLimit,
},
{ error: 'credit_limit_reached', creditsUsed: creditResult.creditsUsed, creditLimit: creditResult.creditLimit },
{ status: 402 }
);
}
Expand Down
10 changes: 10 additions & 0 deletions apps/product-helper/app/api/projects/[id]/tech-stack/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { withProjectAuth } from '@/lib/api/with-project-auth';
import { checkAndDeductCredits } from '@/lib/db/queries';
import { db } from '@/lib/db/drizzle';
import { projects, projectData } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
Expand Down Expand Up @@ -54,6 +55,15 @@ export const GET = withProjectAuth(
*/
export const POST = withProjectAuth(
async (req, { team, projectId }) => {
// Credit check
const creditResult = await checkAndDeductCredits(team.id, 100);
if (!creditResult.allowed) {
return NextResponse.json(
{ error: 'credit_limit_reached', creditsUsed: creditResult.creditsUsed, creditLimit: creditResult.creditLimit },
{ status: 402 }
);
}

// Fetch project with projectData
const project = await db.query.projects.findFirst({
where: and(
Expand Down
9 changes: 9 additions & 0 deletions apps/product-helper/components/chat/chat-window.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import React, { type FormEvent, type ReactNode, useRef, useEffect, useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { type Message, useChat } from 'ai/react';
import { toast } from 'sonner';
import { ChatMessageBubble } from './chat-message-bubble';
Expand Down Expand Up @@ -207,6 +208,7 @@ export function ChatWindow({
projectId,
chatOptions = {},
}: ChatWindowProps) {
const router = useRouter();
const [currentPhase, setCurrentPhase] = useState<ArtifactPhase | null>(null);
const [currentNode, setCurrentNode] = useState<string | null>(null);

Expand All @@ -218,6 +220,13 @@ export function ChatWindow({
body: chatOptions.body,
streamMode: 'text',
onResponse: (response) => {
if (response.status === 402) {
toast.error('Free credits used up', {
description: 'Upgrade your plan to continue using AI features.',
action: { label: 'Upgrade', onClick: () => router.push('/pricing') },
});
return;
}
const phase = response.headers.get('X-Current-Phase') as ArtifactPhase | null;
if (phase) setCurrentPhase(phase);
},
Expand Down
4 changes: 4 additions & 0 deletions apps/product-helper/components/quick-start/progress-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export function ProgressCards({
);

if (!response.ok) {
if (response.status === 402) {
onError?.('credit_limit_reached');
return;
}
const errorText = await response.text().catch(() => 'Unknown error');
onError?.(errorText || `Server error: ${response.status}`);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Zap, ArrowRight } from 'lucide-react';
import { Zap, ArrowRight, Crown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
Expand Down Expand Up @@ -338,6 +338,52 @@ function ErrorPhase({
errorMessage: string;
onRetry: () => void;
}) {
const router = useRouter();
const isCreditLimit = errorMessage === 'credit_limit_reached';

if (isCreditLimit) {
return (
<>
<DialogHeader>
<DialogTitle>Free Credits Used Up</DialogTitle>
<DialogDescription>
You&apos;ve used all your free credits. Upgrade your plan to
continue generating PRDs.
</DialogDescription>
</DialogHeader>

<div
className="rounded-md border p-4"
style={{
backgroundColor: 'hsl(var(--primary) / 0.05)',
borderColor: 'hsl(var(--primary) / 0.2)',
}}
>
<p
className="text-sm"
style={{ color: 'var(--text-primary)' }}
>
Upgrade to get unlimited AI generation, priority support, and more.
</p>
</div>

<DialogFooter>
<Button
onClick={() => router.push('/pricing')}
className="gap-2"
style={{
backgroundColor: 'hsl(var(--primary))',
color: '#FFFFFF',
}}
>
<Crown className="h-4 w-4" />
Upgrade
</Button>
</DialogFooter>
</>
);
}

return (
<>
<DialogHeader>
Expand Down
2 changes: 1 addition & 1 deletion apps/product-helper/lib/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export async function checkAndDeductCredits(
): Promise<{ allowed: boolean; creditsUsed: number; creditLimit: number }> {
const team = await db.query.teams.findFirst({
where: eq(teams.id, teamId),
columns: { creditsUsed: true, creditLimit: true, subscriptionStatus: true, planName: true },
columns: { creditsUsed: true, creditLimit: true, subscriptionStatus: true },
});

if (!team) return { allowed: false, creditsUsed: 0, creditLimit: 0 };
Expand Down
Loading
Loading