Skip to main content

Lead Module

Overview

The Lead module captures marketing leads from the FlowPOS landing page. When a prospect submits the demo request or contact form, the backend persists the lead to the database and dispatches it to Chatwoot for sales follow-up — all in a single public API call requiring no authentication.


Architecture

Hexagonal Structure

apps/backend/src/lead/
├── lead.module.ts # Module registration
├── domain/
│ ├── lead.constants.ts # LeadSource / LeadLocale / LeadVertical types
│ ├── lead.entity.ts # Lead domain entity + CreateLeadInput type
│ ├── lead-repository.domain.ts # ILeadRepository port + LEAD_REPOSITORY token
│ └── lead-destination.port.ts # ILeadDestinationPort port + LEAD_DESTINATION_PORT token
├── application/
│ ├── create-lead.service.ts # Persist lead, then dispatch to destination
│ └── create-lead.service.spec.ts # Unit tests
├── infrastructure/
│ ├── lead.repository.ts # Kysely implementation of ILeadRepository
│ ├── chatwoot-lead-destination.adapter.ts # Chatwoot HTTP adapter (ILeadDestinationPort)
│ └── chatwoot-lead-destination.adapter.spec.ts # Unit tests
└── interfaces/
├── lead.controller.ts # POST /leads endpoint
├── lead.controller.spec.ts # Unit tests
└── dtos/
├── create-lead.dto.ts # Request body validation + Swagger schema
└── lead-created-response.dto.ts # Response shape (Swagger)

Dependency Injection

Symbol-based tokens keep hexagonal boundaries clean:

TokenDefined inImplemented by
LEAD_REPOSITORYdomain/lead-repository.domain.tsLeadRepository (Kysely)
LEAD_DESTINATION_PORTdomain/lead-destination.port.tsChatwootLeadDestinationAdapter

Both are registered in lead.module.ts with { provide: TOKEN, useClass: Implementation }.


Domain Concepts

Lead entity

FieldTypeRequiredDescription
idstringautoUUID primary key
businessIdstring | nullnoReserved for associating a lead with an existing business account
namestringyesFull name of the prospect
emailstringyesContact email (unique per submission)
source"demo" | "contact"yesOrigin form
locale"es" | "en"yesPreferred language
businessNamestring | nullnoProspect's company name
whatsappNumberstring | nullnoWhatsApp phone (used for Chatwoot contact)
phonestring | nullnoAlternative phone
vertical"restaurant" | "retail" | nullnoBusiness type
locationsCountnumber | nullnoNumber of locations
currentPosstring | nullnoPOS system currently in use
inquiryMessagestring | nullnoFree-text message from the prospect
notesstring | nullnoInternal notes from the landing page
createdAtDateautoCreation timestamp

Type constants (lead.constants.ts)

Literal union types are defined once as const arrays and re-exported as TypeScript types:

LEAD_SOURCES   = ["demo", "contact"]
LEAD_LOCALES = ["es", "en"]
LEAD_VERTICALS = ["restaurant", "retail"]

These are consumed by the entity, the DTO (@IsEnum), and the Swagger enum metadata — a single source of truth.


Application Use Case

CreateLeadService.execute(input)

  1. Calls ILeadRepository.insert(input) — persists the lead and returns the full Lead record.
  2. Calls ILeadDestinationPort.dispatch(lead) — fires-and-does-not-block lead persistence (the adapter swallows all Chatwoot errors internally).
  3. Returns the persisted Lead.

Error contract: Repository errors propagate (500 to caller). Chatwoot errors are swallowed by the adapter — a Chatwoot outage never rejects the HTTP request.


Infrastructure

LeadRepository

Inserts a row into the lead table via Kysely .insertInto().values().returningAll(). Maps the DB row back to the Lead domain entity, handling nullable fields and ensuring createdAt is always a Date.

ChatwootLeadDestinationAdapter

Dispatches a lead to Chatwoot CRM in four sequential steps:

  1. Search for existing contact by email (GET /contacts/search).
  2. Create contact if not found (POST /contacts), using whatsappNumber ?? phone as the phone number.
  3. Create conversation linked to the contact in the configured marketing inbox.
  4. Add label marketing-lead and post a private activity note with enriched lead details.

All Chatwoot env vars are optional — if any are missing, dispatch is skipped with a WARN log. All HTTP errors are caught and logged at ERROR level without re-throwing.

Required env vars:

VarDescription
CHATWOOT_API_URLBase URL of the Chatwoot instance
CHATWOOT_API_TOKENAPI access token
CHATWOOT_ACCOUNT_IDAccount ID (numeric)
CHATWOOT_MARKETING_INBOX_IDInbox ID used for marketing leads

API Endpoints

POST /leads

Authentication: None (public endpoint, @IsPublic()).

Request body:

{
"name": "María García",
"email": "maria@restaurante.gt",
"source": "demo",
"locale": "es",
"businessName": "Restaurante El Fogón",
"whatsappNumber": "50299887766",
"vertical": "restaurant",
"locationsCount": 2,
"currentPos": "Square",
"inquiryMessage": "Necesitamos un POS que maneje cuentas separadas."
}

Required fields: name, email, source, locale.

Response 201:

{ "id": "550e8400-e29b-41d4-a716-446655440000" }

Response 400: Validation error details from class-validator.


Bruno API Collection

Requests are in api-client/flowpos/collections/leads/:

FileDescription
create lead (demo).ymlFull restaurant demo request
create lead (contact).ymlFull retail contact form submission
create lead (minimal).ymlMinimal payload (required fields only)

The response id is stored in $leadId global env var after each request.


Design Decisions

businessId is always null The controller hardcodes businessId: null. The field exists in the domain to allow future association of a lead with an existing business account (e.g., existing customer requesting an upgrade). No UI for this exists today.

Chatwoot errors never fail the request Lead persistence and Chatwoot dispatch are intentionally decoupled. A Chatwoot outage must not block the landing page. The adapter catches and logs all Chatwoot errors. If re-delivery is needed in the future, a BullMQ retry queue would be the appropriate extension point.

No rate limiting on this endpoint The /leads endpoint is currently unprotected from spam. The existing ThrottlerModule in AppModule applies globally but does not have a specific rule for this route. If abuse is observed, add @Throttle({ default: { limit: 5, ttl: 60000 } }) to the endpoint.