Lead Module
Overview
The Lead module captures marketing leads from the FlowPOS landing page and lets FIXX platform staff search them later.
Two HTTP surfaces, one domain:
| Surface | Auth | Purpose |
|---|---|---|
POST /leads | Public (@IsPublic()) | Landing-page demo / contact form |
GET /platform-billing/leads and GET /platform-billing/leads/:leadId | PlatformAdminGuard (FIXX super / owner) | Operator inbox |
When a prospect submits the form, the backend persists the lead, dispatches it to Chatwoot, and in production emails the sales inbox — all in a single public API call. Chatwoot conversation/contact ids are stored on the row when dispatch succeeds so the inbox can deep-link into the conversation.
Operator access, menu filtering, and Remote Config: Platform Billing — Access Control. Incident handling: Platform leads troubleshooting.
Shipped inbox + Chatwoot refs in PR #754.
Architecture
Hexagonal Structure
apps/backend/src/lead/
├── lead.module.ts # Module registration; exports LeadQueryService
├── domain/
│ ├── lead.constants.ts # LeadSource / LeadLocale / LeadVertical types
│ ├── lead.entity.ts # Lead, LeadListRow, LeadListParams, ChatwootLeadRefs
│ ├── lead-repository.domain.ts # ILeadRepository port + LEAD_REPOSITORY token
│ ├── lead-destination.port.ts # ILeadDestinationPort + LEAD_DESTINATION_PORT token
│ ├── lead-notification.port.ts # ILeadNotificationPort + LEAD_NOTIFICATION_PORT token
│ ├── lead-notification-message.ts # Pure subject/text/html builder
│ ├── lead-search.util.ts # ILIKE wildcard escape for operator search
│ └── chatwoot-conversation-url.util.ts # Dashboard URL from stored conversation id
├── application/
│ ├── create-lead.service.ts # Persist lead, then Chatwoot + email
│ ├── lead-query.service.ts # Operator list/detail (maps to PlatformLead* DTOs)
│ └── platform-lead.dto.ts # PlatformLeadListItem / PlatformLeadDetail
├── infrastructure/
│ ├── lead.repository.ts # Kysely implementation of ILeadRepository
│ ├── chatwoot-lead-destination.adapter.ts # Chatwoot HTTP adapter (ILeadDestinationPort)
│ └── sendgrid-lead-notification.adapter.ts # SendGrid adapter (ILeadNotificationPort)
└── interfaces/
├── lead.controller.ts # POST /leads
└── dtos/
├── create-lead.dto.ts
└── lead-created-response.dto.ts
The operator HTTP adapter lives outside this folder so platform-admin auth stays with billing:
apps/backend/src/platform-billing/interfaces/platform-leads.controller.tsapps/backend/src/platform-billing/interfaces/dtos/list-leads-query.dto.ts
LeadModule exports LeadQueryService. PlatformBillingModule imports LeadModule and registers PlatformLeadsController.
PWA (operator UI only — do not add this to apps/web-app/):
- List:
apps/frontend-pwa/src/pages/platform-billing/PlatformBillingLeadsPage.tsx - Detail:
PlatformBillingLeadDetailPage.tsx - Search state:
leadSearchReducer.ts/usePlatformLeadSearch.ts - Client:
platformBillingService.listLeads/getLead
Layer responsibilities
| Layer | Owns | Does not own |
|---|---|---|
| Domain | Lead fields, search-pattern escaping, Chatwoot URL shape, repository/destination/notification ports | HTTP paths, Chatwoot REST URLs, SendGrid, pagination envelopes |
| Application | Persist-then-dispatch (CreateLeadService); list/detail mapping (LeadQueryService) | Provider HTTP, Kysely, Firebase |
| Infrastructure | Kysely lead table, Chatwoot REST, SendGrid | Who may list leads |
| Interfaces | Public POST /leads; FIXX GET /platform-billing/leads* | Chatwoot conversation creation |
Dependency Injection
| Token | Defined in | Implemented by |
|---|---|---|
LEAD_REPOSITORY | domain/lead-repository.domain.ts | LeadRepository (Kysely) |
LEAD_DESTINATION_PORT | domain/lead-destination.port.ts | ChatwootLeadDestinationAdapter |
LEAD_NOTIFICATION_PORT | domain/lead-notification.port.ts | SendgridLeadNotificationAdapter |
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 |
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 |
chatwootConversationId | number | null | no | Stored after a successful Chatwoot dispatch |
chatwootContactId | number | null | no | Stored after a successful Chatwoot dispatch |
createdAt | Date | auto | Creation timestamp |
LeadListRow is the list projection: identity, source, locale, phones, vertical — no inquiryMessage / notes / Chatwoot ids. Those appear only on detail.
Type constants (lead.constants.ts)
LEAD_SOURCES = ["demo", "contact"]
LEAD_LOCALES = ["es", "en"]
LEAD_VERTICALS = ["restaurant", "retail"]
These are consumed by the entity, create DTO (@IsEnum), list-query DTO, and Swagger enum metadata.
Operator search (lead-search.util.ts)
search is a partial match against name, email, and business_name using ILIKE … ESCAPE '\'. %, _, and \ in the user term are escaped so they are literal, not SQL wildcards.
Application Use Cases
CreateLeadService.execute(input)
ILeadRepository.insert(input)— persist and return theLead.ILeadDestinationPort.dispatch(lead)andILeadNotificationPort.notify(lead)in parallel. Neither may fail the request (dispatchmust not throw;CreateLeadServicealso swallows throws).- When dispatch returns
{ conversationId, contactId },ILeadRepository.setChatwootRefswrites those ids. A persistence failure is logged and the HTTP response still succeeds — the lead row exists; the inbox Chatwoot link will be missing. - Returns the persisted
Lead(with Chatwoot ids when they were stored).
Error contract: Repository errors propagate (500 to caller). Chatwoot and SendGrid errors never reject POST /leads.
LeadQueryService
| Method | Behavior |
|---|---|
listLeads(params) | Offset page (page 1-based, size default 20 / max 100), newest createdAt first. Maps LeadListRow → PlatformLeadListItem (createdAt as ISO string). |
getLead(leadId) | findById; 404 Lead <id> not found when missing. Builds chatwootUrl from Chatwoot origin + account id + stored conversation id. |
chatwootUrl is application mapping, not a stored column. buildChatwootConversationUrl returns null when origin, account id, or conversation id is missing — historical rows from before Chatwoot refs, or a skipped dispatch, render no link.
Infrastructure
LeadRepository
Kysely adapter for ILeadRepository: insert, setChatwootRefs, findById, list. List applies optional source / locale equality filters plus the escaped search pattern. mapLead includes the Chatwoot id columns.
ChatwootLeadDestinationAdapter
Dispatches a lead to Chatwoot CRM:
- Search contact by email (
GET …/contacts/search). - Create contact if missing (
POST …/contacts), usingwhatsappNumber ?? phone. - Create conversation in the configured inbox;
additional_attributes.lead_idis the FlowPOS lead id. - Label
marketing-lead; post a private activity note with enriched fields. - Return
{ conversationId, contactId }so the application layer can persist them.
All HTTP errors are caught and logged. Unconfigured Chatwoot returns null with a WARN — the public POST still returns 201.
Deploy names the adapter actually reads (must match the support module / Doppler, not older docs that used CHATWOOT_API_URL):
| Name | Role |
|---|---|
CHATWOOT_BASE_URL | Chatwoot origin (also used to build the operator deep-link) |
CHATWOOT_API_TOKEN | API access token |
CHATWOOT_ACCOUNT_ID | Account id |
CHATWOOT_MARKETING_INBOX_ID | Preferred marketing inbox when set |
CHATWOOT_INBOX_ID | Fallback inbox (production currently runs a single inbox) |
If CHATWOOT_BASE_URL, token, account id, or both inbox ids are missing, dispatch is skipped. Using CHATWOOT_API_URL / CHATWOOT_MARKETING_INBOX_ID alone is the silent-skip bug this adapter was rewritten to stop repeating.
SendgridLeadNotificationAdapter
Emails the saved lead to the sales inbox after persist. Prospect-supplied strings are HTML-escaped; Reply-To is the prospect's address; From is the verified SendGrid sender (SENDGRID_FROM_EMAIL).
When it sends
DEPLOY_ENV (fallback NODE_ENV) | Recipient |
|---|---|
production or beta | LEAD_NOTIFICATION_EMAIL |
staging, localhost, development | skip, unless COMMUNICATION_EMAIL_OVERRIDE is a single valid email — then send there for a local proof |
Staging Cloud Run does not set LEAD_NOTIFICATION_EMAIL, so a missed env check cannot mail the real inbox from staging. Missing LEAD_NOTIFICATION_EMAIL or SENDGRID_API_KEY in production is an error log that names the variable (never the body). SendGrid failures log lead.id only.
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" }
The public response is the id only. Chatwoot ids are not returned to the landing page.
Response 400: Validation error details from class-validator.
GET /platform-billing/leads
Authentication: Firebase session + PlatformAdminGuard. @SkipLocationAccess() — this list is not tenant-scoped.
Query:
| Param | Default | Constraints |
|---|---|---|
page | 1 | Integer ≥ 1 |
size | 20 | Integer 1–100 |
search | unset | Max 100 chars; partial name / email / business_name |
source | unset | demo | contact |
locale | unset | es | en |
Response: standard offset pagination (generateOffsetPagination). Each results[] item is a PlatformLeadListItem — no inquiry message, notes, or Chatwoot ids.
GET /platform-billing/leads/:leadId
Authentication: same as list. :leadId is a UUID (ParseUUIDPipe).
Response 200: PlatformLeadDetail — list fields plus businessId, locationsCount, currentPos, inquiryMessage, notes, chatwootConversationId, chatwootContactId, chatwootUrl.
Response 404: unknown id.
Response 403: caller is not an active FIXX super / owner (FlowPOS staff access only.).
Operator UI (PWA)
Routes (FIXX menu, Remote Config platformBillingModule):
| Path | Page |
|---|---|
/platform-billing/leads | Search + table |
/platform-billing/leads/:leadId | Full record + Chatwoot link |
Search text is committed on submit (COMMIT_SEARCH); source and locale filters apply immediately and reset the page to 1. Only page is mirrored into the URL search params. API 403 renders platformBilling.forbidden.
Detail shows chatwootUrl as “Open in Chatwoot” (target=_blank, rel=noopener noreferrer) or the missing-conversation copy when the URL is null.
After changing config/firebase/remote-config/pwaMenu.json, publish Firebase Remote Config pwaMenu or the Leads child will not appear until caches refresh. menu-icon.ts maps /platform-billing/leads → list_alt as a lag fallback.
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 on create
The public controller hardcodes businessId: null. The field exists in the domain to allow future association with an existing business account. No UI for this exists today. List/detail still return the column.
Chatwoot and email errors never fail the public request Lead persistence is decoupled from both Chatwoot and SendGrid. An outage of either must not block the landing page. If re-delivery is needed, a BullMQ retry queue would be the appropriate extension point.
List payloads omit the message body
LeadListRow / PlatformLeadListItem do not include inquiryMessage or notes. Those are detail-only so a paginated inbox cannot leak prospect text into logs or list screenshots.
Chatwoot dashboard URL is derived Do not persist the URL. Origin and account id come from config; conversation id comes from the row. Missing any piece → no link, including every lead captured before Chatwoot refs existed.
No extra rate limit on POST /leads
The endpoint relies on the global ThrottlerModule. If abuse is observed, add a tighter @Throttle on LeadController.create.