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:
| Token | Defined in | Implemented by |
|---|---|---|
LEAD_REPOSITORY | domain/lead-repository.domain.ts | LeadRepository (Kysely) |
LEAD_DESTINATION_PORT | domain/lead-destination.port.ts | ChatwootLeadDestinationAdapter |
Both are registered in lead.module.ts with { provide: TOKEN, useClass: Implementation }.
Domain Concepts
Lead entity
| Field | Type | Required | Description |
|---|---|---|---|
id | string | auto | UUID primary key |
businessId | string | null | no | Reserved for associating a lead with an existing business account |
name | string | yes | Full name of the prospect |
email | string | yes | Contact email (unique per submission) |
source | "demo" | "contact" | yes | Origin form |
locale | "es" | "en" | yes | Preferred language |
businessName | string | null | no | Prospect's company name |
whatsappNumber | string | null | no | WhatsApp phone (used for Chatwoot contact) |
phone | string | null | no | Alternative phone |
vertical | "restaurant" | "retail" | null | no | Business type |
locationsCount | number | null | no | Number of locations |
currentPos | string | null | no | POS system currently in use |
inquiryMessage | string | null | no | Free-text message from the prospect |
notes | string | null | no | Internal notes from the landing page |
createdAt | Date | auto | Creation 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)
- Calls
ILeadRepository.insert(input)— persists the lead and returns the fullLeadrecord. - Calls
ILeadDestinationPort.dispatch(lead)— fires-and-does-not-block lead persistence (the adapter swallows all Chatwoot errors internally). - 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:
- Search for existing contact by email (
GET /contacts/search). - Create contact if not found (
POST /contacts), usingwhatsappNumber ?? phoneas the phone number. - Create conversation linked to the contact in the configured marketing inbox.
- Add label
marketing-leadand 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:
| Var | Description |
|---|---|
CHATWOOT_API_URL | Base URL of the Chatwoot instance |
CHATWOOT_API_TOKEN | API access token |
CHATWOOT_ACCOUNT_ID | Account ID (numeric) |
CHATWOOT_MARKETING_INBOX_ID | Inbox 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/:
| File | Description |
|---|---|
create lead (demo).yml | Full restaurant demo request |
create lead (contact).yml | Full retail contact form submission |
create lead (minimal).yml | Minimal 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.