Proyecto Final: SaaS AI-First Completo
En esta lección construimos una aplicación SaaS AI-First de principio a fin: un asistente de documentación inteligente que permite a los usuarios subir documentos, chatear con ellos (RAG), usar agentes con herramientas, y todo con autenticación, billing y deploy a producción.
Arquitectura del proyecto
proyecto-ai-saas/
├── apps/
│ ├── web/ # Frontend Next.js
│ │ ├── app/
│ │ │ ├── (auth)/ # Login, register
│ │ │ ├── (dashboard)/ # Panel principal
│ │ │ ├── chat/[id]/ # Conversaciones
│ │ │ └── api/
│ │ │ ├── chat/ # Chat endpoint
│ │ │ ├── upload/ # File upload
│ │ │ └── webhooks/ # Stripe webhooks
│ │ └── components/
│ │ ├── ChatWindow.tsx
│ │ ├── FileUploader.tsx
│ │ └── PricingTable.tsx
│ └── worker/ # Background jobs
│ ├── embed.ts # Embedding pipeline
│ └── process.ts # Document processing
├── packages/
│ ├── ai/ # Lógica AI compartida
│ │ ├── prompts.ts
│ │ ├── rag.ts
│ │ └── tools.ts
│ └── db/ # Schema Prisma
│ └── schema.prisma
├── docker-compose.yml
└── turbo.json
1. Schema de base de datos
// packages/db/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
plan Plan @default(FREE)
tokensUsed Int @default(0)
tokensLimit Int @default(10000)
createdAt DateTime @default(now())
documents Document[]
conversations Conversation[]
stripeCustomerId String?
}
enum Plan {
FREE
PRO
ENTERPRISE
}
model Document {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
title String
content String
mimeType String
chunks Chunk[]
status DocumentStatus @default(PROCESSING)
createdAt DateTime @default(now())
}
enum DocumentStatus {
PROCESSING
READY
ERROR
}
model Chunk {
id String @id @default(cuid())
documentId String
document Document @relation(fields: [documentId], references: [id])
content String
embedding Unsupported("vector(1536)")
metadata Json
position Int
@@index([embedding], type: Hnsw(ops: VectorCosineOps))
}
model Conversation {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
title String
messages Message[]
createdAt DateTime @default(now())
}
model Message {
id String @id @default(cuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id])
role String // user, assistant, system, tool
content String
tokensUsed Int @default(0)
createdAt DateTime @default(now())
}
2. Pipeline de documentos
// packages/ai/rag.ts
import { OpenAI } from 'openai';
import { prisma } from '@repo/db';
const openai = new OpenAI();
export async function processDocument(documentId: string) {
const doc = await prisma.document.findUniqueOrThrow({
where: { id: documentId },
});
try {
// 1. Extraer texto según tipo
const text = await extractText(doc.content, doc.mimeType);
// 2. Dividir en chunks
const chunks = splitIntoChunks(text, {
maxTokens: 500,
overlap: 50,
});
// 3. Generar embeddings en batch
const embeddings = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: chunks.map(c => c.content),
});
// 4. Guardar chunks con embeddings
await prisma.$transaction(
chunks.map((chunk, i) =>
prisma.$executeRaw`
INSERT INTO "Chunk" (id, "documentId", content, embedding, metadata, position)
VALUES (
${`chunk-${documentId}-${i}`},
${documentId},
${chunk.content},
${embeddings.data[i].embedding}::vector,
${JSON.stringify(chunk.metadata)}::jsonb,
${i}
)
`
)
);
await prisma.document.update({
where: { id: documentId },
data: { status: 'READY' },
});
} catch (error) {
await prisma.document.update({
where: { id: documentId },
data: { status: 'ERROR' },
});
throw error;
}
}
function splitIntoChunks(
text: string,
options: { maxTokens: number; overlap: number }
): { content: string; metadata: Record<string, any> }[] {
const sentences = text.split(/(?<=[.!?])\s+/);
const chunks: { content: string; metadata: Record<string, any> }[] = [];
let current = '';
let sentenceIndex = 0;
for (const sentence of sentences) {
if ((current + sentence).length > options.maxTokens * 4) {
if (current) {
chunks.push({
content: current.trim(),
metadata: { startSentence: sentenceIndex - current.split(/[.!?]/).length },
});
}
// Overlap: mantener últimas palabras
const words = current.split(' ');
current = words.slice(-options.overlap).join(' ') + ' ' + sentence;
} else {
current += ' ' + sentence;
}
sentenceIndex++;
}
if (current.trim()) {
chunks.push({ content: current.trim(), metadata: { sentenceIndex } });
}
return chunks;
}
3. Chat con RAG y herramientas
// packages/ai/chat.ts
import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';
import { prisma } from '@repo/db';
export async function chat(
conversationId: string,
userMessage: string,
userId: string
) {
// Verificar tokens del usuario
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
});
if (user.tokensUsed >= user.tokensLimit) {
throw new Error('Token limit reached. Upgrade your plan.');
}
// Guardar mensaje del usuario
await prisma.message.create({
data: {
conversationId,
role: 'user',
content: userMessage,
},
});
// Buscar documentos relevantes
const relevantChunks = await searchDocuments(userMessage, userId);
const context = relevantChunks
.map(c => `[Doc: ${c.title}]\n${c.content}`)
.join('\n\n---\n\n');
// Historial de conversación
const history = await prisma.message.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
take: 20,
});
const result = streamText({
model: openai('gpt-4o'),
system: `Eres un asistente de documentación inteligente.
Usa el contexto proporcionado para responder preguntas.
Si no encuentras la respuesta en el contexto, dilo claramente.
Cita la fuente del documento cuando sea posible.
CONTEXTO DE DOCUMENTOS:
${context}`,
messages: history.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
tools: {
searchDocs: tool({
description: 'Busca en los documentos del usuario',
parameters: z.object({
query: z.string().describe('Búsqueda semántica'),
}),
execute: async ({ query }) => {
const results = await searchDocuments(query, userId);
return results.map(r => ({
document: r.title,
content: r.content,
}));
},
}),
listDocuments: tool({
description: 'Lista los documentos disponibles',
parameters: z.object({}),
execute: async () => {
const docs = await prisma.document.findMany({
where: { userId, status: 'READY' },
select: { id: true, title: true, createdAt: true },
});
return docs;
},
}),
},
maxSteps: 3,
onFinish: async ({ text, usage }) => {
// Guardar respuesta
await prisma.message.create({
data: {
conversationId,
role: 'assistant',
content: text,
tokensUsed: (usage?.totalTokens ?? 0),
},
});
// Actualizar tokens del usuario
await prisma.user.update({
where: { id: userId },
data: {
tokensUsed: { increment: usage?.totalTokens ?? 0 },
},
});
},
});
return result;
}
async function searchDocuments(query: string, userId: string, limit = 5) {
const queryEmbedding = await new OpenAI().embeddings.create({
model: 'text-embedding-3-small',
input: query,
});
const results = await prisma.$queryRaw`
SELECT c.content, d.title, 1 - (c.embedding <=> ${queryEmbedding.data[0].embedding}::vector) as similarity
FROM "Chunk" c
JOIN "Document" d ON c."documentId" = d.id
WHERE d."userId" = ${userId}
AND d.status = 'READY'
ORDER BY c.embedding <=> ${queryEmbedding.data[0].embedding}::vector
LIMIT ${limit}
`;
return results as { content: string; title: string; similarity: number }[];
}
4. Frontend - Chat Window
// apps/web/components/ChatWindow.tsx
'use client';
import { useChat } from 'ai/react';
import { useState } from 'react';
export function ChatWindow({ conversationId }: { conversationId: string }) {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: `/api/chat/${conversationId}`,
});
return (
<div className="flex flex-col h-screen max-w-3xl mx-auto">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m) => (
<div
key={m.id}
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] px-4 py-2 rounded-lg ${
m.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<div className="prose prose-sm">{m.content}</div>
{/* Mostrar tool calls */}
{m.toolInvocations?.map((tool, i) => (
<div key={i} className="mt-2 text-xs bg-gray-200 p-2 rounded">
🔧 {tool.toolName}: {JSON.stringify(tool.result).slice(0, 100)}...
</div>
))}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 px-4 py-2 rounded-lg animate-pulse">
Pensando...
</div>
</div>
)}
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Pregunta sobre tus documentos..."
className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
Enviar
</button>
</div>
</form>
</div>
);
}
5. Billing con Stripe
// apps/web/app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { prisma } from '@repo/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch {
return new Response('Webhook error', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (userId) {
await prisma.user.update({
where: { id: userId },
data: {
plan: 'PRO',
tokensLimit: 500000,
stripeCustomerId: session.customer as string,
},
});
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const user = await prisma.user.findFirst({
where: { stripeCustomerId: subscription.customer as string },
});
if (user) {
await prisma.user.update({
where: { id: user.id },
data: { plan: 'FREE', tokensLimit: 10000 },
});
}
break;
}
}
return new Response('ok');
}
6. Deploy a producción
# docker-compose.prod.yml
version: '3.8'
services:
web:
build:
context: .
dockerfile: apps/web/Dockerfile
environment:
DATABASE_URL: ${DATABASE_URL}
OPENAI_API_KEY: ${OPENAI_API_KEY}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
ports:
- "3000:3000"
worker:
build:
context: .
dockerfile: apps/worker/Dockerfile
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
OPENAI_API_KEY: ${OPENAI_API_KEY}
deploy:
replicas: 2
Script de deploy
#!/bin/bash
set -e
echo "🚀 Deploying AI SaaS..."
# 1. Run tests
npm run test
# 2. Build
npm run build
# 3. Run migrations
npx prisma migrate deploy
# 4. Enable pgvector
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
# 5. Deploy
docker compose -f docker-compose.prod.yml up -d --build
echo "✅ Deploy complete!"
Resumen del proyecto
| Componente | Tecnología |
|---|---|
| Frontend | Next.js 14 + React + Tailwind |
| Backend/API | Next.js API Routes (Edge) |
| AI | Vercel AI SDK + OpenAI |
| Base de datos | PostgreSQL + pgvector + Prisma |
| Cola de trabajos | BullMQ + Redis |
| Auth | NextAuth.js |
| Billing | Stripe |
| Deploy | Docker + Vercel/Railway |
| Monitoreo | Prometheus + Grafana |
Este proyecto demuestra cómo integrar todas las técnicas del curso en una aplicación real lista para producción. Desde RAG y function calling hasta billing y deploy, cada pieza trabaja en conjunto para crear una experiencia AI-First completa.