Skip to content

Guarda información

Guardar información

El framework proporciona una arquitectura robusta para persistir datos durante las conversaciones con tu bot. Esta funcionalidad es esencial para mantener el estado de las interacciones, almacenar información de usuarios, gestionar entidades de negocio y construir aplicaciones con memoria a largo plazo.

La persistencia de datos se estructura en tres capas fundamentales: interfaces (que definen la forma de los datos), modelos (que encapsulan la lógica de negocio), y repositorios (que gestionan las operaciones de base de datos). Esta separación de responsabilidades facilita el mantenimiento, testing y escalabilidad de tu aplicación.

Conceptos fundamentales

Antes de implementar la persistencia de datos, es importante comprender tres elementos clave del framework:

IEntityData

IEntityData es la interfaz base que deben extender las interfaces de datos de tus entidades. Aporta los campos estándar id?: string, createdAt?: number | null y discardedAt?: number | null — el repositorio los completa automáticamente al hacer create(), así que nunca los asignes tú mismo.

import { IEntityData } from '@wabot-dev/framework'
export interface IMisDatos extends IEntityData {
// Tus propiedades personalizadas
nombre: string
email: string
}

Entity

Entity es una clase base genérica que encapsula datos y proporciona lógica de negocio asociada. Todas las entidades del dominio (como Offer, Prospect, Sale, etc.) deben extender de Entity, pasando como tipo genérico la interfaz que define su estructura de datos.

Las entidades no solo almacenan datos, sino que también contienen métodos que implementan reglas de negocio, validaciones y transformaciones relacionadas con esos datos.

import { Entity, IEntityData } from '@wabot-dev/framework'
export interface IMisDatos extends IEntityData {
nombre: string
}
export class MiEntidad extends Entity<IMisDatos> {
// Getters para acceder a los datos
get nombre() {
return this.data.nombre
}
// Métodos de lógica de negocio
cambiarNombre(nuevoNombre: string) {
this.data.nombre = nuevoNombre
}
}

Entity expone id y createdAt (lanzan error si la entidad aún no fue creada), update(partial), wasCreated() y validate().

@repository + CrudRepository + @query (recomendado)

Un repositorio es una clase decorada con @repository({ table, constructor }) que extiende CrudRepository. El decorador genera las implementaciones de los métodos CRUD (find, findOrThrow, findByIds, findAll, create, update, delete) y, por cada propiedad declarada con @query(), genera la consulta automáticamente a partir del nombre del método — sin escribir SQL.

import { CrudRepository, query, repository } from '@wabot-dev/framework'
@repository({ table: 'producto', constructor: Producto })
export class ProductoRepository extends CrudRepository<Producto> {
@query() declare findByCategoria: (categoria: string) => Promise<Producto[]>
@query() declare findOneBySlug: (slug: string) => Promise<Producto | null>
@query() declare countByCategoria: (categoria: string) => Promise<number>
@query() declare existsBySlug: (slug: string) => Promise<boolean>
@query() declare deleteByCategoria: (categoria: string) => Promise<void>
}

Las consultas se declaran como propiedades declare (solo la firma — la implementación la genera el decorador). @repository aplica @singleton() por ti: inyecta el repositorio directamente donde lo necesites.

No necesitas configurar la base de datos: el runner del proyecto elige el adaptador automáticamente. Con DATABASE_URL apuntando a PostgreSQL usa PgJsonRepositoryAdapter (cada fila guarda los datos como JSON); sin base de datos usa MemoryRepositoryAdapter.

Extensiones por adaptador (@queryExtension)

Cuando una consulta no puede expresarse con el DSL de nombres (JOINs, agregaciones, rutas JSON), declárala con @queryExtension() y provee una implementación por adaptador con @memExtension(Repo) y/o @pgExtension(Repo):

import {
CrudRepository, memExtension, MemoryRepositoryExtension,
pgExtension, PgRepositoryExtension, query, queryExtension, repository,
} from '@wabot-dev/framework'
@repository({ table: 'producto', constructor: Producto })
export class ProductoRepository extends CrudRepository<Producto> {
@query() declare findByCategoria: (categoria: string) => Promise<Producto[]>
@queryExtension() declare findTopVendidos: (limit: number) => Promise<Producto[]>
}
@pgExtension(ProductoRepository)
export class ProductoPgQueries extends PgRepositoryExtension<Producto> {
async findTopVendidos(limit: number) {
const sql = `
SELECT ${this['columns']} FROM ${this['table']}
ORDER BY ("data"->>'ventas')::numeric DESC
LIMIT $1
`
return this['query'](sql, [limit])
}
}
@memExtension(ProductoRepository)
export class ProductoMemoryQueries extends MemoryRepositoryExtension<Producto> {
async findTopVendidos(limit: number) {
return [...this.items.values()]
.sort((a, b) => b['data'].ventas - a['data'].ventas)
.slice(0, limit)
.map((p) => this.clone(p))
}
}

Paso 1: Definir las interfaces

El primer paso es crear las interfaces que definen la estructura de los datos que necesitas almacenar. La interfaz principal de cada entidad debe extender de IEntityData; las interfaces de objetos anidados son interfaces planas normales. También puedes incluir tipos y constantes que representen valores válidos para ciertos campos.

src/models/interfaces/IProspect.ts

import { IEntityData } from '@wabot-dev/framework'
// Constantes para valores predefinidos
export const PROSPECT_INTEREST_LEVEL_OPTIONS = ['high', 'medium', 'low', 'not_qualified'] as const
export const PROSPECT_STATUS_OPTIONS = [
'new',
'contacted',
'qualified',
'negotiating',
'won',
'lost',
] as const
export const PROSPECT_BUDGET_RANGE_OPTIONS = [
'under_5k',
'5k_to_15k',
'15k_to_50k',
'over_50k',
'not_disclosed',
] as const
export const PROSPECT_CONTACT_SOURCE_OPTIONS = [
'whatsapp',
'telegram',
'website',
'referral',
'social_media',
] as const
export const PROSPECT_INDUSTRY_OPTIONS = [
'technology',
'retail',
'healthcare',
'finance',
'manufacturing',
'education',
'other',
] as const
// Tipos derivados de las constantes
export type IProspectInterestLevel = (typeof PROSPECT_INTEREST_LEVEL_OPTIONS)[number]
export type IProspectStatus = (typeof PROSPECT_STATUS_OPTIONS)[number]
export type IProspectBudgetRange = (typeof PROSPECT_BUDGET_RANGE_OPTIONS)[number]
export type IProspectContactSource = (typeof PROSPECT_CONTACT_SOURCE_OPTIONS)[number]
export type IProspectIndustry = (typeof PROSPECT_INDUSTRY_OPTIONS)[number]
// Interface para información de contacto (objeto anidado — interfaz plana)
export interface IProspectContactInfo {
type: 'email' | 'phone' | 'linkedin' | 'website'
value: string
verified: boolean
}
// Interface para objeciones registradas
export interface IProspectObjection {
type: string
description: string
resolved: boolean
resolution?: string
timestamp: string
}
// Interface para notas del prospecto
export interface IProspectNote {
category: string
content: string
priority: 'low' | 'normal' | 'high'
timestamp: string
}
// Interface principal del prospecto — extiende IEntityData
// (id y createdAt los aporta IEntityData; el repositorio los completa)
export interface IProspectData extends IEntityData {
chatId: string
firstName?: string
lastName?: string
companyName?: string
industry?: IProspectIndustry
position?: string
interestLevel: IProspectInterestLevel
status: IProspectStatus
budgetRange?: IProspectBudgetRange
contactSource: IProspectContactSource
contactInfo: IProspectContactInfo[]
objections: IProspectObjection[]
notes: IProspectNote[]
lastInteractionDate: string
}

src/models/interfaces/IOffer.ts

import { IEntityData } from '@wabot-dev/framework'
export const OFFER_STATUS_OPTIONS = [
'draft',
'sent',
'negotiating',
'accepted',
'rejected',
'expired',
] as const
export type IOfferStatus = (typeof OFFER_STATUS_OPTIONS)[number]
export interface IOfferNote {
type: string
content: string
priority: 'low' | 'normal' | 'high'
timestamp: string
}
export interface IOfferData extends IEntityData {
chatId: string
prospectId: string
productName: string
quantity: number
unitPrice: number
discount: number
totalPrice: number
status: IOfferStatus
validUntil: string
specialConditions?: string
notes: IOfferNote[]
updatedAt: string
acceptedAt?: string
rejectedAt?: string
statusHistory: Array<{
status: IOfferStatus
reason?: string
timestamp: string
}>
}

Paso 2: Crear los modelos (Entidades)

Los modelos extienden de Entity y encapsulan tanto los datos como la lógica de negocio relacionada. Estos modelos proporcionan métodos para manipular datos de forma segura y aplicar reglas de negocio.

src/models/Prospect.ts

import { Entity } from '@wabot-dev/framework'
import {
IProspectData,
IProspectContactInfo,
IProspectObjection,
IProspectNote,
IProspectInterestLevel,
IProspectStatus,
IProspectBudgetRange,
IProspectIndustry,
} from './interfaces/IProspect'
export class Prospect extends Entity<IProspectData> {
// Getters para acceso a propiedades principales
get chatId() {
return this.data.chatId
}
get fullName() {
const { firstName, lastName } = this.data
if (firstName && lastName) return `${firstName} ${lastName}`
if (firstName) return firstName
return 'Prospecto sin nombre'
}
get interestLevel() {
return this.data.interestLevel
}
get status() {
return this.data.status
}
get budgetRange() {
return this.data.budgetRange
}
get contactInfo() {
return this.data.contactInfo
}
get objections() {
return this.data.objections
}
get notes() {
return this.data.notes
}
// Métodos de lógica de negocio
/**
* Verifica si el prospecto está calificado para recibir una oferta formal
*/
isQualified(): boolean {
const hasHighInterest =
this.data.interestLevel === 'high' || this.data.interestLevel === 'medium'
const hasReasonableBudget =
this.data.budgetRange === '5k_to_15k' ||
this.data.budgetRange === '15k_to_50k' ||
this.data.budgetRange === 'over_50k'
const hasContactInfo = this.data.contactInfo.length > 0
const isInValidStatus = this.data.status === 'contacted' || this.data.status === 'qualified'
return hasHighInterest && hasReasonableBudget && hasContactInfo && isInValidStatus
}
/**
* Verifica si tiene objeciones sin resolver
*/
hasUnresolvedObjections(): boolean {
return this.data.objections.some((obj) => !obj.resolved)
}
/**
* Calcula el puntaje de prioridad del prospecto (0-100)
*/
calculatePriorityScore(): number {
let score = 0
// Puntos por nivel de interés
const interestScores = { high: 40, medium: 25, low: 10, not_qualified: 0 }
score += interestScores[this.data.interestLevel]
// Puntos por rango de presupuesto
const budgetScores = {
over_50k: 30,
'15k_to_50k': 20,
'5k_to_15k': 10,
under_5k: 5,
not_disclosed: 0,
}
score += this.data.budgetRange ? budgetScores[this.data.budgetRange] : 0
// Puntos por estado
const statusScores = {
negotiating: 20,
qualified: 15,
contacted: 10,
new: 5,
won: 0,
lost: 0,
}
score += statusScores[this.data.status]
// Penalización por objeciones sin resolver
if (this.hasUnresolvedObjections()) {
score -= 10
}
return Math.max(0, Math.min(100, score))
}
// Métodos para actualizar información personal
setPersonalInfo(firstName: string, lastName?: string) {
this.data.firstName = firstName
if (lastName) this.data.lastName = lastName
}
setCompanyInfo(companyName: string, industry?: IProspectIndustry, position?: string) {
this.data.companyName = companyName
if (industry) this.data.industry = industry
if (position) this.data.position = position
}
setInterestLevel(level: IProspectInterestLevel) {
this.data.interestLevel = level
}
setStatus(status: IProspectStatus) {
this.data.status = status
}
setBudgetRange(range: IProspectBudgetRange) {
this.data.budgetRange = range
}
// Métodos para gestionar información de contacto
addContactInfo(contactInfo: IProspectContactInfo) {
// Evitar duplicados
const exists = this.data.contactInfo.some(
(info) => info.type === contactInfo.type && info.value === contactInfo.value,
)
if (!exists) {
this.data.contactInfo.push(contactInfo)
}
}
verifyContactInfo(type: string, value: string) {
const contact = this.data.contactInfo.find((info) => info.type === type && info.value === value)
if (contact) {
contact.verified = true
}
}
// Métodos para gestionar objeciones
addObjection(objection: IProspectObjection) {
this.data.objections.push(objection)
}
resolveObjection(objectionType: string, resolution: string) {
const objection = this.data.objections.find(
(obj) => obj.type === objectionType && !obj.resolved,
)
if (objection) {
objection.resolved = true
objection.resolution = resolution
}
}
// Métodos para gestionar notas
addNote(note: IProspectNote) {
this.data.notes.push(note)
}
getHighPriorityNotes(): IProspectNote[] {
return this.data.notes.filter((note) => note.priority === 'high')
}
// Método para actualizar fecha de última interacción
updateLastInteraction() {
this.data.lastInteractionDate = new Date().toISOString()
}
}

src/models/Offer.ts

import { Entity } from '@wabot-dev/framework'
import { IOfferData, IOfferStatus, IOfferNote } from './interfaces/IOffer'
export class Offer extends Entity<IOfferData> {
// Getters
get chatId() {
return this.data.chatId
}
get prospectId() {
return this.data.prospectId
}
get status() {
return this.data.status
}
get totalPrice() {
return this.data.totalPrice
}
get notes() {
return this.data.notes
}
get isExpired() {
return new Date(this.data.validUntil) < new Date()
}
get isActive() {
return ['draft', 'sent', 'negotiating'].includes(this.data.status) && !this.isExpired
}
// Métodos de lógica de negocio
/**
* Calcula el precio total con descuento aplicado
*/
calculateTotal(): number {
const subtotal = this.data.quantity * this.data.unitPrice
const discountAmount = subtotal * (this.data.discount / 100)
const total = subtotal - discountAmount
this.data.totalPrice = total
return total
}
/**
* Calcula el ahorro total por el descuento
*/
calculateSavings(): number {
const subtotal = this.data.quantity * this.data.unitPrice
return subtotal * (this.data.discount / 100)
}
/**
* Actualiza el estado de la oferta y registra en el historial
*/
updateStatus(newStatus: IOfferStatus, reason?: string) {
this.data.status = newStatus
this.data.updatedAt = new Date().toISOString()
// Registrar en historial
this.data.statusHistory.push({
status: newStatus,
reason,
timestamp: new Date().toISOString(),
})
// Actualizar fechas específicas según el estado
if (newStatus === 'accepted') {
this.data.acceptedAt = new Date().toISOString()
} else if (newStatus === 'rejected') {
this.data.rejectedAt = new Date().toISOString()
}
}
/**
* Agrega una nota a la oferta
*/
addNote(note: IOfferNote) {
this.data.notes.push(note)
}
/**
* Actualiza los términos de la oferta (precio, cantidad, descuento)
*/
updateTerms(params: {
quantity?: number
unitPrice?: number
discount?: number
validUntil?: string
specialConditions?: string
}) {
if (params.quantity) this.data.quantity = params.quantity
if (params.unitPrice) this.data.unitPrice = params.unitPrice
if (params.discount !== undefined) this.data.discount = params.discount
if (params.validUntil) this.data.validUntil = params.validUntil
if (params.specialConditions) this.data.specialConditions = params.specialConditions
this.calculateTotal()
this.data.updatedAt = new Date().toISOString()
}
/**
* Extiende la fecha de vencimiento de la oferta
*/
extendValidity(newValidUntil: string) {
this.data.validUntil = newValidUntil
this.data.updatedAt = new Date().toISOString()
}
/**
* Obtiene el historial de cambios de estado
*/
getStatusHistory() {
return this.data.statusHistory.sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
)
}
/**
* Verifica si se puede modificar la oferta
*/
canBeModified(): boolean {
return ['draft', 'sent', 'negotiating'].includes(this.data.status) && !this.isExpired
}
}

Paso 3: Crear los repositorios

Los repositorios gestionan las operaciones de persistencia. Se decoran con @repository() y extienden CrudRepository para heredar las operaciones CRUD básicas. La mayoría de las consultas se declaran con @query() y el framework las genera a partir del nombre del método; las consultas que el DSL no puede expresar se declaran con @queryExtension() y se implementan por adaptador.

src/repositories/ProspectRepository.ts

import { CrudRepository, query, queryExtension, repository } from '@wabot-dev/framework'
import { Prospect } from '@/models/Prospect'
import {
IProspectBudgetRange,
IProspectIndustry,
IProspectStatus,
} from '@/models/interfaces/IProspect'
export interface IProspectRepositoryExtensions {
findQualifiedProspects(): Promise<Prospect[]>
findWithUnresolvedObjections(): Promise<Prospect[]>
findInactiveProspects(daysInactive: number): Promise<Prospect[]>
}
@repository({ table: 'prospect', constructor: Prospect })
export class ProspectRepository
extends CrudRepository<Prospect, IProspectRepositoryExtensions>
implements IProspectRepositoryExtensions
{
// Consultas generadas a partir del nombre del método
@query() declare findOneByChatId: (chatId: string) => Promise<Prospect | null>
@query() declare findByStatus: (status: IProspectStatus) => Promise<Prospect[]>
@query() declare findByBudgetRange: (budgetRange: IProspectBudgetRange) => Promise<Prospect[]>
@query() declare findByIndustry: (industry: IProspectIndustry) => Promise<Prospect[]>
// Consultas complejas — una implementación por adaptador (ver abajo)
@queryExtension() declare findQualifiedProspects: () => Promise<Prospect[]>
@queryExtension() declare findWithUnresolvedObjections: () => Promise<Prospect[]>
@queryExtension() declare findInactiveProspects: (daysInactive: number) => Promise<Prospect[]>
}

src/repositories/ProspectPgQueries.ts — implementación PostgreSQL de las extensiones:

import { pgExtension, PgRepositoryExtension } from '@wabot-dev/framework'
import { Prospect } from '@/models/Prospect'
import { IProspectRepositoryExtensions, ProspectRepository } from './ProspectRepository'
@pgExtension(ProspectRepository)
export class ProspectPgQueries
extends PgRepositoryExtension<Prospect>
implements IProspectRepositoryExtensions
{
async findQualifiedProspects(): Promise<Prospect[]> {
const sql = `
SELECT ${this['columns']}
FROM ${this['table']}
WHERE (data->>'interestLevel' = 'high' OR data->>'interestLevel' = 'medium')
AND data->>'budgetRange' IN ('5k_to_15k', '15k_to_50k', 'over_50k')
AND (data->>'status' = 'contacted' OR data->>'status' = 'qualified')
ORDER BY data->>'lastInteractionDate' DESC
`
return this['query'](sql, [])
}
async findWithUnresolvedObjections(): Promise<Prospect[]> {
const sql = `
SELECT ${this['columns']}
FROM ${this['table']}
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(data->'objections') AS objection
WHERE objection->>'resolved' = 'false'
)
ORDER BY data->>'lastInteractionDate' DESC
`
return this['query'](sql, [])
}
async findInactiveProspects(daysInactive: number): Promise<Prospect[]> {
const sql = `
SELECT ${this['columns']}
FROM ${this['table']}
WHERE (data->>'lastInteractionDate')::timestamp < NOW() - ($1 || ' days')::interval
AND data->>'status' NOT IN ('won', 'lost')
ORDER BY data->>'lastInteractionDate' ASC
`
return this['query'](sql, [String(daysInactive)])
}
}

src/repositories/ProspectMemoryQueries.ts — la misma lógica para el adaptador en memoria (útil en desarrollo y tests):

import { memExtension, MemoryRepositoryExtension } from '@wabot-dev/framework'
import { Prospect } from '@/models/Prospect'
import { IProspectRepositoryExtensions, ProspectRepository } from './ProspectRepository'
@memExtension(ProspectRepository)
export class ProspectMemoryQueries
extends MemoryRepositoryExtension<Prospect>
implements IProspectRepositoryExtensions
{
async findQualifiedProspects(): Promise<Prospect[]> {
return [...this.items.values()]
.filter((p) => p.isQualified())
.map((p) => this.clone(p))
}
async findWithUnresolvedObjections(): Promise<Prospect[]> {
return [...this.items.values()]
.filter((p) => p.hasUnresolvedObjections())
.map((p) => this.clone(p))
}
async findInactiveProspects(daysInactive: number): Promise<Prospect[]> {
const cutoff = Date.now() - daysInactive * 24 * 60 * 60 * 1000
return [...this.items.values()]
.filter(
(p) =>
new Date(p['data'].lastInteractionDate).getTime() < cutoff &&
!['won', 'lost'].includes(p['data'].status),
)
.map((p) => this.clone(p))
}
}

src/repositories/OfferRepository.ts

import { CrudRepository, query, queryExtension, repository } from '@wabot-dev/framework'
import { Offer } from '@/models/Offer'
import { IOfferStatus } from '@/models/interfaces/IOffer'
export interface IOfferRepositoryExtensions {
findActiveOffers(): Promise<Offer[]>
findExpiringOffers(daysUntilExpiration: number): Promise<Offer[]>
findAcceptedInDateRange(startDate: string, endDate: string): Promise<Offer[]>
getTotalAcceptedValue(): Promise<number>
}
@repository({ table: 'offer', constructor: Offer })
export class OfferRepository
extends CrudRepository<Offer, IOfferRepositoryExtensions>
implements IOfferRepositoryExtensions
{
// Última oferta del chat (ordena por fecha de creación, descendente)
@query() declare findOneByChatIdOrderByCreatedAtDesc: (chatId: string) => Promise<Offer | null>
@query() declare findByProspectId: (prospectId: string) => Promise<Offer[]>
@query() declare findByStatus: (status: IOfferStatus) => Promise<Offer[]>
@query() declare findByStatusIn: (statuses: IOfferStatus[]) => Promise<Offer[]>
@query() declare findByTotalPriceGteAndTotalPriceLte: (min: number, max: number) => Promise<Offer[]>
// Consultas con fechas/agregaciones — extensión por adaptador
@queryExtension() declare findActiveOffers: () => Promise<Offer[]>
@queryExtension() declare findExpiringOffers: (days: number) => Promise<Offer[]>
@queryExtension() declare findAcceptedInDateRange: (start: string, end: string) => Promise<Offer[]>
@queryExtension() declare getTotalAcceptedValue: () => Promise<number>
}

src/repositories/OfferPgQueries.ts

import { pgExtension, PgRepositoryExtension } from '@wabot-dev/framework'
import { Offer } from '@/models/Offer'
import { IOfferRepositoryExtensions, OfferRepository } from './OfferRepository'
@pgExtension(OfferRepository)
export class OfferPgQueries
extends PgRepositoryExtension<Offer>
implements IOfferRepositoryExtensions
{
async findActiveOffers(): Promise<Offer[]> {
const sql = `
SELECT ${this['columns']}
FROM ${this['table']}
WHERE data->>'status' IN ('draft', 'sent', 'negotiating')
AND (data->>'validUntil')::timestamp > NOW()
ORDER BY data->>'validUntil' ASC
`
return this['query'](sql, [])
}
async findExpiringOffers(daysUntilExpiration: number): Promise<Offer[]> {
const sql = `
SELECT ${this['columns']}
FROM ${this['table']}
WHERE data->>'status' IN ('sent', 'negotiating')
AND (data->>'validUntil')::timestamp
BETWEEN NOW() AND NOW() + ($1 || ' days')::interval
ORDER BY data->>'validUntil' ASC
`
return this['query'](sql, [String(daysUntilExpiration)])
}
async findAcceptedInDateRange(startDate: string, endDate: string): Promise<Offer[]> {
const sql = `
SELECT ${this['columns']}
FROM ${this['table']}
WHERE data->>'status' = 'accepted'
AND (data->>'acceptedAt')::timestamp BETWEEN $1::timestamp AND $2::timestamp
ORDER BY data->>'acceptedAt' DESC
`
return this['query'](sql, [startDate, endDate])
}
async getTotalAcceptedValue(): Promise<number> {
const result = await this['pool'].query(
`SELECT SUM((data->>'totalPrice')::numeric) AS total
FROM ${this['table']}
WHERE data->>'status' = 'accepted'`,
)
return Number(result.rows[0]?.total ?? 0)
}
}

Si tu proyecto también corre sin PostgreSQL (desarrollo o tests), agrega la implementación @memExtension(OfferRepository) equivalente — un repositorio que solo registra @pgExtension lanza error al invocar la extensión bajo el adaptador en memoria.

src/repositories/OfferRepository.ts

import { Offer } from '@/models/Offer'
import { PgCrudRepository, singleton } from '@wabot-dev/framework'
import { Pool } from 'pg'
@singleton()
export class OfferRepository extends PgCrudRepository<Offer> {
constructor(pool: Pool) {
super(pool, {
schema: 'sales_bot',
table: 'offer',
constructor: Offer,
})
}
/**
* Busca una oferta por chatId (última oferta del chat)
*/
async findByChatId(chatId: string): Promise<Offer | null> {
const query = `
SELECT ${this.columns}
FROM ${this.table}
WHERE data @> $1::jsonb
ORDER BY data->>'createdAt' DESC
LIMIT 1
`
const items = await this.query(query, [JSON.stringify({ chatId })])
return items[0] ?? null
}
/**
* Busca todas las ofertas de un prospecto por prospectId
*/
async findByProspectId(prospectId: string): Promise<Offer[]> {
const query = `
SELECT ${this.columns}
FROM ${this.table}
WHERE data @> $1::jsonb
ORDER BY data->>'createdAt' DESC
`
return await this.query(query, [JSON.stringify({ prospectId })])
}
/**
* Busca ofertas por estado
*/
async findByStatus(status: string): Promise<Offer[]> {
const query = `
SELECT ${this.columns}
FROM ${this.table}
WHERE data @> $1::jsonb
ORDER BY data->>'updatedAt' DESC
`
return await this.query(query, [JSON.stringify({ status })])
}
/**
* Busca ofertas activas (no expiradas y en estado draft, sent, o negotiating)
*/
async findActiveOffers(): Promise<Offer[]> {
const query = `
SELECT ${this.columns}
FROM ${this.table}
WHERE data->>'status' IN ('draft', 'sent', 'negotiating')
AND (data->>'validUntil')::timestamp > NOW()
ORDER BY data->>'validUntil' ASC
`
return await this.query(query, [])
}
/**
* Busca ofertas próximas a expirar (dentro de los próximos X días)
*/
async findExpiringOffers(daysUntilExpiration: number): Promise<Offer[]> {
const query = `
SELECT ${this.columns}
FROM ${this.table}
WHERE data->>'status' IN ('sent', 'negotiating')
AND (data->>'validUntil')::timestamp BETWEEN NOW() AND NOW() + INTERVAL '${daysUntilExpiration} days'
ORDER BY data->>'validUntil' ASC
`
return await this.query(query, [])
}
/**
* Busca ofertas por rango de precio total
*/
async findByPriceRange(minPrice: number, maxPrice: number): Promise<Offer[]> {
const query = `
SELECT ${this.columns}
FROM ${this.table}
WHERE (data->>'totalPrice')::numeric BETWEEN $1 AND $2
ORDER BY (data->>'totalPrice')::numeric DESC
`
return await this.query(query, [minPrice, maxPrice])
}
/**
* Busca ofertas aceptadas en un rango de fechas
*/
async findAcceptedInDateRange(startDate: string, endDate: string): Promise<Offer[]> {
const query = `
SELECT ${this.columns}
FROM ${this.table}
WHERE data->>'status' = 'accepted'
AND (data->>'acceptedAt')::timestamp BETWEEN $1::timestamp AND $2::timestamp
ORDER BY data->>'acceptedAt' DESC
`
return await this.query(query, [startDate, endDate])
}
/**
* Calcula el valor total de ofertas aceptadas
*/
async getTotalAcceptedValue(): Promise<number> {
const query = `
SELECT SUM((data->>'totalPrice')::numeric) as total
FROM ${this.table}
WHERE data->>'status' = 'accepted'
`
const result = await this.query(query, [])
return result[0]?.total || 0
}
}

Consultas automáticas con @repository y @query

El sistema de consultas automáticas genera la consulta a partir del nombre del método (SQL bajo PostgreSQL, filtrado en memoria con el adaptador in-memory). Esto elimina la necesidad de escribir SQL repetitivo para filtros, ordenamiento y paginación comunes.

Estructura del nombre del método

<prefijo>[By<condiciones>][OrderBy<campo>Asc|Desc][Limit<N>]

Prefijos disponibles:

PrefijoRetornaSQL generado
findPromise<Entidad[]>SELECT ... FROM tabla
findOnePromise<Entidad | null>SELECT ... LIMIT 1
countPromise<number>SELECT COUNT(*) FROM tabla
existsPromise<boolean>SELECT EXISTS(...)
deletePromise<void>DELETE FROM tabla

Operadores de condición

Las condiciones se especifican después de By. El operador se infiere del sufijo del nombre del campo:

Sufijo en el nombreOperador SQLEjemplo de método
(ninguno)= $1findByStatus
Not<> $1findByStatusNot
LikeLIKE $1findByNameLike
NotLikeNOT LIKE $1findByNameNotLike
In= ANY($1)findByStatusIn — recibe string[]
NotInNOT ANY($1)findByStatusNotIn
Gt / GreaterThan> $1findByPriceGt
Gte / GreaterThanEqual>= $1findByPriceGte
Lt / LessThan< $1findByPriceLt
Lte / LessThanEqual<= $1findByPriceLte
IsNullIS NULLfindByDeletedAtIsNull — sin parámetro
IsNotNullIS NOT NULLfindByDeletedAtIsNotNull

Múltiples condiciones con And / Or

@repository({ schema: 'tienda', table: 'producto', constructor: Producto })
export class ProductoRepository extends CrudRepository<Producto> {
// WHERE data->>'categoria' = $1 AND (data->>'precio')::numeric > $2
@query() declare findByCategoriaAndPrecioGt: (categoria: string, precio: number) => Promise<Producto[]>
// WHERE data->>'status' = $1 OR data->>'status' = $2
@query() declare findByStatusOrStatus: (s1: string, s2: string) => Promise<Producto[]>
// WHERE data->>'activo' IS NOT NULL AND (data->>'stock')::numeric > $1
@query() declare findByActivoIsNotNullAndStockGt: (stock: number) => Promise<Producto[]>
}

El orden de los parámetros del método debe coincidir con el orden de las condiciones en el nombre, de izquierda a derecha. Los operadores IsNull e IsNotNull no consumen parámetro.

Ordenamiento y límite

@repository({ schema: 'tienda', table: 'producto', constructor: Producto })
export class ProductoRepository extends CrudRepository<Producto> {
// ORDER BY data->>'precio' ASC
@query() declare findByCategoriaOrderByPrecioAsc: (categoria: string) => Promise<Producto[]>
// ORDER BY data->>'precio' DESC, data->>'nombre' ASC
@query() declare findAllOrderByPrecioDescNombreAsc: () => Promise<Producto[]>
// LIMIT 10
@query() declare findByCategoriaLimit10: (categoria: string) => Promise<Producto[]>
// ORDER BY + LIMIT
@query() declare findAllOrderByPrecioAscLimit5: () => Promise<Producto[]>
}

Bajo PostgreSQL, los campos del JSON se comparan como texto en ORDER BY — para ordenar numéricamente un campo “caliente”, define una columna física con add.columns (ver más abajo).

findOne aplica LIMIT 1 automáticamente — no uses Limit con findOne.

Campos especiales: id y createdAt

Bajo PostgreSQL, los campos id y createdAt se almacenan como columnas nativas (id TEXT, created_at TIMESTAMP) y usan el índice directamente. Todos los demás campos se extraen del JSON (data->>'campo').

@query() declare findOneById: (id: string) => Promise<Producto | null>
// → WHERE id = $1
@query() declare findByCreatedAtGte: (fecha: Date) => Promise<Producto[]>
// → WHERE created_at >= $1

Columnas adicionales indexadas (add.columns)

Para campos que se consultan frecuentemente y necesitan un índice nativo en PostgreSQL (por ejemplo, para ordenamiento numérico correcto o búsquedas eficientes), puedes definir columnas físicas adicionales con add.columns. Es un campo específico del adaptador PostgreSQL — el adaptador en memoria lo ignora:

import { CrudRepository, IPgRepositoryConfig, query, repository } from '@wabot-dev/framework'
const productoConfig: IPgRepositoryConfig<Producto> = {
schema: 'tienda',
table: 'producto',
constructor: Producto,
add: {
columns: {
// Columna física 'status' sincronizada automáticamente con data.status
status: {
type: 'TEXT',
value: (p) => p.status,
},
// Columna física 'precio' con tipo numérico para ordenamiento correcto
precio: {
type: 'NUMERIC',
value: (p) => p.precio,
},
},
},
}
@repository(productoConfig)
export class ProductoRepository extends CrudRepository<Producto> {
@query() declare findByStatus: (status: string) => Promise<Producto[]>
@query() declare findAllOrderByPrecioAsc: () => Promise<Producto[]>
}

El framework crea y migra estas columnas automáticamente. Los valores se actualizan en cada create() y update().

Ejemplo completo de repositorio con consultas automáticas

import {
CrudRepository, pgExtension, PgRepositoryExtension,
query, queryExtension, repository,
} from '@wabot-dev/framework'
import { Reserva } from '@/models/Reserva'
@repository({ schema: 'restaurante', table: 'reserva', constructor: Reserva })
export class ReservaRepository extends CrudRepository<Reserva> {
// Búsquedas simples
@query() declare findOneByChatId: (chatId: string) => Promise<Reserva | null>
@query() declare findByStatus: (status: string) => Promise<Reserva[]>
@query() declare findByFecha: (fecha: string) => Promise<Reserva[]>
// Múltiples condiciones
@query() declare findByFechaAndStatus: (fecha: string, status: string) => Promise<Reserva[]>
@query() declare findByStatusAndPersonasGte: (status: string, personas: number) => Promise<Reserva[]>
// Con IN (recibe array)
@query() declare findByStatusIn: (statuses: string[]) => Promise<Reserva[]>
// Existencia y conteo
@query() declare existsByChatIdAndStatus: (chatId: string, status: string) => Promise<boolean>
@query() declare countByFechaAndStatus: (fecha: string, status: string) => Promise<number>
// Con ordenamiento
@query() declare findByStatusOrderByFechaAsc: (status: string) => Promise<Reserva[]>
@query() declare findAllOrderByFechaAscLimit20: () => Promise<Reserva[]>
// Eliminación
@query() declare deleteByStatus: (status: string) => Promise<void>
// Consulta compleja que no puede expresarse con el DSL — extensión por adaptador
@queryExtension() declare findSolapadas: (fecha: string, mesa: number) => Promise<Reserva[]>
}
@pgExtension(ReservaRepository)
export class ReservaPgQueries extends PgRepositoryExtension<Reserva> {
async findSolapadas(fecha: string, mesa: number): Promise<Reserva[]> {
return this['query'](
`SELECT ${this['columns']} FROM ${this['table']}
WHERE data->>'fecha' = $1
AND data->>'mesa' = $2
AND data->>'status' NOT IN ('cancelada', 'no_show')`,
[fecha, String(mesa)]
)
}
}

Paso 4: Usar los repositorios en los módulos

Ahora que tenemos las interfaces, modelos y repositorios, podemos utilizarlos en nuestros módulos para persistir y recuperar información durante las conversaciones.

src/modules/ProspectManagementModule.ts

import { Chat, mindsetModule, description, isString, isOptional, isNotEmpty, isIn } from '@wabot-dev/framework'
import { ProspectRepository } from '@/repositories/ProspectRepository'
import { Prospect } from '@/models/Prospect'
import {
CreateProspectReq,
UpdateProspectInfoReq,
AddProspectObjectionReq,
ResolveObjectionReq
} from './requests'
@mindsetModule()
export class ProspectManagementModule {
constructor(
private prospectRepository: ProspectRepository,
private chat: Chat,
) {}
@description(`
Crea un nuevo registro de prospecto cuando un usuario inicia una conversación
por primera vez. Debe ser llamada al inicio de la interacción para establecer
el perfil básico del prospecto antes de comenzar la calificación.
`)
async createProspect(req: CreateProspectReq) {
const prospect = new Prospect({
chatId: this.chat.id,
firstName: req.firstName,
lastName: req.lastName,
companyName: req.companyName,
industry: req.industry,
position: req.position,
interestLevel: 'medium', // Por defecto
status: 'new',
contactSource: req.contactSource,
contactInfo: [],
objections: [],
notes: [],
lastInteractionDate: new Date().toISOString(),
})
await this.prospectRepository.create(prospect) // asigna id y createdAt
return {
prospectId: prospect.id,
message: 'Prospecto creado exitosamente'
}
}
@description(`
Actualiza la información del prospecto cuando se descubre nueva información
durante la conversación, como nivel de interés, rango de presupuesto, estado
de calificación, o datos de contacto adicionales.
`)
async updateProspectInfo(req: UpdateProspectInfoReq) {
const prospect = await this.resolveProspect()
if (req.interestLevel) prospect.setInterestLevel(req.interestLevel)
if (req.status) prospect.setStatus(req.status)
if (req.budgetRange) prospect.setBudgetRange(req.budgetRange)
if (req.contactInfo) {
prospect.addContactInfo(req.contactInfo)
}
prospect.updateLastInteraction()
await this.prospectRepository.update(prospect)
return {
prospectId: prospect.id,
isQualified: prospect.isQualified(),
priorityScore: prospect.calculatePriorityScore(),
message: 'Información del prospecto actualizada'
}
}
@description(`
Registra una objeción expresada por el prospecto durante la conversación.
Debe ser llamada cada vez que el prospecto manifieste una preocupación,
duda o razón por la que no puede avanzar con la compra.
`)
async addProspectObjection(req: AddProspectObjectionReq) {
const prospect = await this.resolveProspect()
prospect.addObjection({
type: req.objectionType,
description: req.objectionDescription,
resolved: false,
timestamp: new Date().toISOString(),
})
prospect.updateLastInteraction()
await this.prospectRepository.update(prospect)
return {
prospectId: prospect.id,
hasUnresolvedObjections: prospect.hasUnresolvedObjections(),
message: 'Objeción registrada'
}
}
@description(`
Marca una objeción como resuelta cuando se ha manejado exitosamente
durante la conversación y el prospecto ha aceptado la respuesta o solución proporcionada.
`)
async resolveObjection(req: ResolveObjectionReq) {
const prospect = await this.resolveProspect()
prospect.resolveObjection(req.objectionType, req.resolution)
prospect.updateLastInteraction()
await this.prospectRepository.update(prospect)
return {
prospectId: prospect.id,
hasUnresolvedObjections: prospect.hasUnresolvedObjections(),
message: 'Objeción resuelta exitosamente'
}
}
/* Método auxiliar para resolver o crear un prospecto*/
private async resolveProspect(): Promise<Prospect> {
let prospect = await this.prospectRepository.findOneByChatId(this.chat.id)
if (!prospect) {
// Crear prospecto automáticamente si no existe
prospect = new Prospect({
chatId: this.chat.id,
interestLevel: 'medium',
status: 'new',
contactSource: 'whatsapp',
contactInfo: [],
objections: [],
notes: [],
lastInteractionDate: new Date().toISOString(),
})
await this.prospectRepository.create(prospect)
}
return prospect
}
}

src/modules/requests/CreateProspectReq.ts

import { description, isString, isNotEmpty, isOptional } from '@wabot-dev/framework'
export class CreateProspectReq {
@isString()
@isNotEmpty()
@description('Primer nombre del prospecto extraído de la conversación.')
firstName: string = ''
@isString()
@isOptional()
@description('Apellido del prospecto si fue mencionado en la conversación.')
lastName: string = ''
@isString()
@isOptional()
@description('Nombre de la empresa u organización del prospecto si fue mencionado.')
companyName: string = ''
@isString()
@isOptional()
@description(`
Industria o sector al que pertenece el prospecto. Usar valores estandarizados
en inglés: 'technology', 'retail', 'healthcare', 'finance', 'manufacturing',
'education', 'other'.
`)
industry: string = ''
@isString()
@isOptional()
@description('Posición o cargo del prospecto en su empresa, como CEO, Director de Ventas, Gerente de Marketing, etc.')
position: string = ''
@isString()
@isNotEmpty()
@description(`Fuente de contacto del prospecto. Usar valores estandarizados: 'whatsapp', 'telegram', 'website', 'referral', 'social_media'.`)
contactSource: string = ''
}

src/modules/requests/UpdateProspectInfoReq.ts

import { description, isString, isOptional } from '@wabot-dev/framework'
export class UpdateProspectInfoReq {
@isString()
@isOptional()
@description(`Nivel de interés del prospecto: 'high' (muy interesado), 'medium' (evaluando opciones), 'low' (pocas dudas), 'not_qualified' (no cumple el perfil).`)
interestLevel: string = ''
@isString()
@isOptional()
@description(`Estado del prospecto: 'new' (primer contacto), 'contacted', 'qualified', 'negotiating', 'won' (venta cerrada), 'lost'.`)
status: string = ''
@isString()
@isOptional()
@description(`Rango de presupuesto: 'under_5k', '5k_to_15k', '15k_to_50k', 'over_50k', 'not_disclosed'.`)
budgetRange: string = ''
}

src/modules/requests/AddProspectObjectionReq.ts

import { description, isString, isNotEmpty } from '@wabot-dev/framework'
export class AddProspectObjectionReq {
@isString()
@isNotEmpty()
@description(`Tipo de objeción estandarizado en inglés: 'price_too_high', 'need_more_time', 'budget_not_available', 'competitor_comparison', 'technical_concerns', etc.`)
objectionType: string = ''
@isString()
@isNotEmpty()
@description('Descripción detallada de la objeción tal como fue expresada por el prospecto, con el contexto completo.')
objectionDescription: string = ''
}

src/modules/requests/ResolveObjectionReq.ts

import { description, isString, isNotEmpty } from '@wabot-dev/framework'
export class ResolveObjectionReq {
@isString()
@isNotEmpty()
@description('Tipo de objeción que se está resolviendo. Debe coincidir con el tipo usado al registrarla.')
objectionType: string = ''
@isString()
@isNotEmpty()
@description('Explicación de cómo se resolvió la objeción, qué solución se ofreció o qué información convenció al prospecto.')
resolution: string = ''
}

Módulo de gestión de ofertas

src/modules/OfferManagementModule.ts

import { Chat, mindsetModule, description, isString, isNumber, isOptional, isNotEmpty, min, max } from '@wabot-dev/framework'
import { CreateOfferReq, UpdateOfferStatusReq, AddOfferNoteReq } from './requests'
import { OfferRepository } from '@/repositories/OfferRepository'
import { ProspectRepository } from '@/repositories/ProspectRepository'
import { Offer } from '@/models/Offer'
@mindsetModule()
export class OfferManagementModule {
constructor(
private offerRepository: OfferRepository,
private prospectRepository: ProspectRepository,
private chat: Chat,
) {}
@description(`
Crea una nueva oferta comercial cuando el prospecto muestra interés
en un producto específico o cuando se ha identificado claramente su
necesidad y presupuesto. Llamar después de calificar al prospecto.
`)
async createOffer(req: CreateOfferReq) {
// Obtener el prospecto asociado
const prospect = await this.prospectRepository.findOneByChatId(this.chat.id)
if (!prospect) {
throw new Error('Debe existir un prospecto antes de crear una oferta')
}
const offer = new Offer({
chatId: this.chat.id,
prospectId: prospect.id,
productName: req.productName,
quantity: req.quantity,
unitPrice: req.unitPrice,
discount: req.discount || 0,
totalPrice: 0, // Se calculará automáticamente
status: 'draft',
validUntil: req.validUntil,
specialConditions: req.specialConditions,
notes: [],
updatedAt: new Date().toISOString(),
statusHistory: [
{
status: 'draft',
timestamp: new Date().toISOString(),
},
],
})
const totalPrice = offer.calculateTotal()
await this.offerRepository.create(offer)
return {
offerId: offer.id,
totalPrice: totalPrice,
savings: offer.calculateSavings(),
message: 'Oferta creada exitosamente',
}
}
@description(`
Actualiza el estado de una oferta existente según el progreso de la
negociación. Usar cuando el prospecto acepta, rechaza, solicita
modificaciones o cuando la oferta expira. Estados válidos:
'draft', 'sent', 'negotiating', 'accepted', 'rejected', 'expired'.
`)
async updateOfferStatus(req: UpdateOfferStatusReq) {
const offer = await this.offerRepository.findOneByChatIdOrderByCreatedAtDesc(this.chat.id)
if (!offer) {
throw new Error('No se encontró una oferta activa para este chat')
}
offer.updateStatus(req.newStatus, req.reason)
await this.offerRepository.update(offer)
return {
offerId: offer.id,
currentStatus: offer.status,
updatedAt: offer.updatedAt,
isActive: offer.isActive,
message: `Oferta actualizada a estado: ${req.newStatus}`,
}
}
@description(`
Registra notas importantes sobre la oferta durante la conversación,
como objeciones del cliente, solicitudes especiales o puntos clave
de la negociación.
`)
async addOfferNote(req: AddOfferNoteReq) {
const offer = await this.offerRepository.findOneByChatIdOrderByCreatedAtDesc(this.chat.id)
if (!offer) {
throw new Error('No se encontró una oferta activa para este chat')
}
offer.addNote({
type: req.noteType,
content: req.noteContent,
timestamp: new Date().toISOString(),
priority: req.priority || 'normal',
})
await this.offerRepository.update(offer)
return {
offerId: offer.id,
notesCount: offer.notes.length,
message: 'Nota agregada exitosamente',
}
}
@description(`
Modifica los términos de una oferta existente cuando el prospecto
solicita cambios en cantidad, precio, descuento o condiciones especiales.
Solo aplica en estados 'draft', 'sent' o 'negotiating' y sin expirar.
`)
async updateOfferTerms(req: UpdateOfferTermsReq) {
const offer = await this.offerRepository.findOneByChatIdOrderByCreatedAtDesc(this.chat.id)
if (!offer) {
throw new Error('No se encontró una oferta activa para este chat')
}
if (!offer.canBeModified()) {
throw new Error('Esta oferta no puede ser modificada en su estado actual')
}
offer.updateTerms({
quantity: req.quantity,
unitPrice: req.unitPrice,
discount: req.discount,
validUntil: req.validUntil,
specialConditions: req.specialConditions,
})
await this.offerRepository.update(offer)
return {
offerId: offer.id,
newTotalPrice: offer.totalPrice,
newSavings: offer.calculateSavings(),
message: 'Términos de la oferta actualizados exitosamente',
}
}
}

src/modules/requests/UpdateOfferTermsReq.ts

import { description, isNumber, isString, isOptional, min, max } from '@wabot-dev/framework'
export class UpdateOfferTermsReq {
@isNumber()
@isOptional()
@min(1)
@description('Nueva cantidad de unidades (número entero positivo).')
quantity: number = 0
@isNumber()
@isOptional()
@min(0)
@description('Nuevo precio unitario en dólares (USD).')
unitPrice: number = 0
@isNumber()
@isOptional()
@min(0)
@max(100)
@description('Nuevo porcentaje de descuento (0-100).')
discount: number = 0
@isString()
@isOptional()
@description('Nueva fecha de vencimiento en formato ISO (YYYY-MM-DD).')
validUntil: string = ''
@isString()
@isOptional()
@description('Condiciones especiales modificadas o adicionales según la negociación.')
specialConditions: string = ''
}

Ejemplo de uso completo en un mindset

Aquí tienes un ejemplo de cómo usar ambos módulos en un mindset de ventas:

src/mindsets/SalesBotMindset.ts

import { mindset, type IMindset, type IMindsetIdentity, type IMindsetModels } from '@wabot-dev/framework'
import { ProspectManagementModule } from '@/modules/ProspectManagementModule'
import { OfferManagementModule } from '@/modules/OfferManagementModule'
@mindset({ modules: [ProspectManagementModule, OfferManagementModule] })
export class SalesBotMindset implements IMindset {
async context(): Promise<string> {
return `
Eres el asistente de ventas de TechSolutions, empresa de software B2B.
Vendemos herramientas de gestión empresarial para Pymes y corporaciones en Latinoamérica.
`
}
async identity(): Promise<IMindsetIdentity> {
return {
name: 'Martín Sales',
language: 'Español',
personality: 'Profesional, persuasivo y orientado a resultados',
emotions:
'Entusiasta al presentar soluciones, empático con las preocupaciones del cliente, y confiado al manejar objeciones',
}
}
async skills(): Promise<string> {
return `
Eres un experto en identificar las necesidades específicas del cliente
mediante preguntas estratégicas que revelan sus puntos de dolor.
Eres un experto en calificar prospectos evaluando su presupuesto, autoridad,
necesidad y timeline (metodología BANT).
Eres un experto en presentar productos de forma persuasiva, destacando
beneficios concretos que resuelven los problemas identificados del cliente.
Eres un experto en manejar objeciones de venta con empatía, convirtiendo
dudas en oportunidades para reforzar el valor de la oferta.
Eres un experto en detectar señales de compra y guiar al cliente hacia
el cierre de forma natural sin presionar.
Eres un experto en usar los módulos de gestión de prospectos y ofertas
para registrar información valiosa durante la conversación.
`
}
async workflow(): Promise<string> {
return `
- Saluda al prospecto de forma profesional y cálida. Si es la primera vez
que interactúas con este prospecto, usa la función createProspect del
módulo de gestión de prospectos para crear su perfil.
- Tu misión principal es calificar al prospecto y, si cumple con los criterios,
crear una oferta comercial personalizada que lo motive a comprar.
- Criterios de calificación (BANT):
* Budget (Presupuesto): Mínimo $5,000 USD disponible
* Authority (Autoridad): Es tomador de decisiones o influye directamente
* Need (Necesidad): Tiene un problema claro que nuestro producto resuelve
* Timeline (Tiempo): Busca implementar una solución en los próximos 6 meses
- Flujo de calificación:
1. Descubre su necesidad con preguntas como:
"¿Qué desafío específico estás buscando resolver?"
"¿Cómo está afectando esto a tu operación actualmente?"
2. Evalúa su presupuesto con tacto:
"¿Han considerado una inversión para resolver esta situación?"
"¿Qué rango de presupuesto están manejando para esta solución?"
3. Confirma su autoridad:
"¿Quiénes más están involucrados en esta decisión?"
"¿Cuál es tu rol en el proceso de selección de proveedores?"
4. Identifica su timeline:
"¿Cuándo necesitarían tener una solución implementada?"
"¿Hay alguna fecha límite o evento que impulse esta decisión?"
- Cada vez que descubras información relevante (nivel de interés, presupuesto,
industria, objeciones, datos de contacto), usa inmediatamente las funciones
del módulo de gestión de prospectos para registrarla:
* updateProspectInfo: Para nivel de interés, presupuesto, estado
* addProspectObjection: Cuando exprese una preocupación o duda
* resolveObjection: Cuando hayas manejado exitosamente una objeción
- Si el prospecto expresa una objeción, regístrala con addProspectObjection
y luego manéjala apropiadamente:
* "Precio muy alto" → Presenta ROI y ahorro proyectado
* "Necesito tiempo" → Crea urgencia con promoción vigente
* "Debo consultarlo" → Ofrece hablar con referencias de clientes actuales
Una vez resuelta, marca como resuelta con resolveObjection.
- Cuando el prospecto esté calificado (cumple criterios BANT), usa la función
createOffer del módulo de gestión de ofertas para crear una propuesta formal.
- Al crear la oferta, asegúrate de:
* Seleccionar el paquete apropiado según su necesidad y presupuesto
* Aplicar descuento si está autorizado (máximo 15% sin aprobación)
* Establecer validez de 7-14 días para crear urgencia
* Incluir condiciones especiales que se hayan negociado
- Durante la negociación de la oferta:
* Usa addOfferNote para registrar puntos importantes de la conversación
* Si solicita cambios, usa updateOfferTerms (validando que esté autorizado)
* Actualiza el estado con updateOfferStatus según avance la negociación
- Información de productos y precios:
Paquete Starter: $5,000 USD
- Hasta 20 usuarios
- Soporte estándar por email
- Implementación básica incluida
- Ideal para: Pequeñas empresas y startups
Paquete Professional: $12,000 USD
- Hasta 100 usuarios
- Soporte prioritario 24/7
- Implementación avanzada y capacitación
- Integraciones con sistemas existentes
- Ideal para: Empresas medianas en crecimiento
Paquete Enterprise: Cotización personalizada
- Usuarios ilimitados
- Soporte premium con gerente dedicado
- Implementación completa y customización
- SLA garantizado
- Ideal para: Grandes corporaciones
- Beneficios clave a destacar:
* Reducción de costos operativos promedio del 35% en 6 meses
* Implementación rápida: 2-4 semanas
* ROI positivo típicamente en 8-10 meses
* Más de 200 empresas en Latinoamérica ya lo usan
* Garantía de satisfacción de 30 días
- Descuentos autorizados:
* Hasta 10% por pago anual anticipado
* Hasta 15% para órdenes múltiples (3+ licencias)
* Descuentos mayores requieren aprobación de gerencia
- Si el prospecto acepta la oferta, actualiza el estado a 'accepted' y
felicítalo por su decisión. Explica los próximos pasos: envío del contrato,
proceso de onboarding, timeline de implementación.
- Si rechaza la oferta, actualiza el estado a 'rejected', pregunta cortésmente
la razón y regístrala en las notas. Mantén la puerta abierta para futuro contacto.
- Mantén siempre un tono profesional pero amigable. No presiones, pero sé
propositivo y guía la conversación hacia el cierre.
`
}
async limits(): Promise<string> {
return `
No puedes ofrecer descuentos superiores al 15% sin autorización previa.
No puedes compartir información sobre márgenes de ganancia, costos de
producción o estrategias de pricing internas.
No puedes garantizar tiempos de entrega o funcionalidades sin confirmar
primero con el equipo técnico.
No puedes hablar negativamente sobre competidores ni hacer comparaciones
desleales.
No puedes prometer características que el producto no posee actualmente.
No puedes revelar información personal de otros clientes sin su autorización.
`
}
async models(): Promise<IMindsetModels> {
return {
llm: [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-4o' },
]
}
}
}

Con esta estructura completa, tu bot vendedor puede:

  1. Crear y gestionar perfiles de prospectos
  2. Calificarlos según criterios BANT
  3. Registrar objeciones y su resolución
  4. Crear ofertas comerciales personalizadas
  5. Dar seguimiento al estado de las negociaciones
  6. Persistir toda la información para análisis posterior

Toda la información se guarda automáticamente en PostgreSQL y está disponible para consultas, reportes y análisis del proceso de ventas.


Transacciones de base de datos

El decorador @transaction() garantiza que todas las operaciones de base de datos dentro de un método se ejecuten de forma atómica: si algo falla, todo se revierte automáticamente.

Cómo funciona

  • @transaction() — envuelve el método en una transacción usando todos los adaptadores registrados (por defecto, PostgreSQL).
  • Si el método lanza un error, se hace ROLLBACK de todas las operaciones.
  • Si el método termina sin error, se hace COMMIT.
  • Las transacciones anidadas funcionan automáticamente con savepoints de PostgreSQL: si una función transaccional llama a otra función transaccional, la interior usa un savepoint en lugar de iniciar una nueva transacción.

Ejemplo básico

import { transaction, injectable } from '@wabot-dev/framework'
import { ProspectRepository } from '@/repositories/ProspectRepository'
import { OfferRepository } from '@/repositories/OfferRepository'
@injectable()
export class SalesService {
constructor(
private prospectRepository: ProspectRepository,
private offerRepository: OfferRepository,
) {}
@transaction()
async closeDeal(prospectId: string, offerAmount: number) {
// Si cualquiera de estas operaciones falla, todas se revierten
const prospect = await this.prospectRepository.findOrThrow(prospectId)
prospect.setStatus('won')
await this.prospectRepository.update(prospect)
const offer = new Offer({ prospectId, amount: offerAmount, status: 'accepted', ... })
await this.offerRepository.create(offer)
// Si el envío del email falla, prospect y offer también se revierten
await this.sendConfirmationEmail(prospect)
}
}

Transacciones en módulos de mindset

Puedes usar @transaction() en métodos de módulos para proteger operaciones críticas:

import { mindsetModule, description, transaction } from '@wabot-dev/framework'
import { ProspectRepository } from '@/repositories/ProspectRepository'
import { OfferRepository } from '@/repositories/OfferRepository'
@mindsetModule()
export class SalesModule {
constructor(
private prospectRepository: ProspectRepository,
private offerRepository: OfferRepository,
private chat: Chat,
) {}
@transaction()
@description(`
Cierra la venta: marca el prospecto como ganado y crea el registro
de la oferta aceptada en una sola operación atómica.
`)
async closeSale(req: CloseSaleReq) {
const prospect = await this.prospectRepository.findOneByChatId(this.chat.id)
if (!prospect) throw new Error('Prospecto no encontrado')
prospect.setStatus('won')
await this.prospectRepository.update(prospect)
const offer = await this.offerRepository.findOneByChatIdOrderByCreatedAtDesc(this.chat.id)
if (!offer) throw new Error('Oferta no encontrada')
offer.updateStatus('accepted')
await this.offerRepository.update(offer)
return { message: 'Venta cerrada exitosamente', prospectId: prospect.id, offerId: offer.id }
}
}

Configuración manual (sin ProjectRunner)

Si estás configurando el proyecto manualmente (sin npx @wabot-dev/create), necesitas registrar el adaptador de transacciones:

import { container, TransactionMetadataStore, PgTransactionAdapter } from '@wabot-dev/framework'
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// Registrar el adaptador bajo el nombre 'default'
const transactionStore = container.resolve(TransactionMetadataStore)
transactionStore.registerAdapter('default', new PgTransactionAdapter(pool))

Si usas el flujo estándar (run(config) en src/_run_.ts) con PostgreSQL configurado, el runner registra el adaptador automáticamente.

Siguiente paso

Aprende a conectar tu bot con los usuarios a través de diferentes plataformas: Controladores y Canales.