Inicio / Inteligencia Artificial / AI-First Full Stack: Construye Apps con IA / Proyecto Final: SaaS AI-First Completo

Proyecto Final: SaaS AI-First Completo

Construye un SaaS AI completo: chat con RAG, agentes, auth, billing, deploy y mejores prácticas de producción.

Avanzado
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

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.

🔒

Ejercicio práctico disponible

Pipeline completo: ingesta, RAG y chat

Desbloquear ejercicios
// Pipeline completo: ingesta, RAG y chat
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes