Skip to main content

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:

SurfaceAuthPurpose
POST /leadsPublic (@IsPublic())Landing-page demo / contact form
GET /platform-billing/leads and GET /platform-billing/leads/:leadIdPlatformAdminGuard (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.ts
  • apps/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​

LayerOwnsDoes not own
DomainLead fields, search-pattern escaping, Chatwoot URL shape, repository/destination/notification portsHTTP paths, Chatwoot REST URLs, SendGrid, pagination envelopes
ApplicationPersist-then-dispatch (CreateLeadService); list/detail mapping (LeadQueryService)Provider HTTP, Kysely, Firebase
InfrastructureKysely lead table, Chatwoot REST, SendGridWho may list leads
InterfacesPublic POST /leads; FIXX GET /platform-billing/leads*Chatwoot conversation creation

Dependency Injection​

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

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
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
chatwootConversationIdnumber | nullnoStored after a successful Chatwoot dispatch
chatwootContactIdnumber | nullnoStored after a successful Chatwoot dispatch
createdAtDateautoCreation 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)​

  1. ILeadRepository.insert(input) — persist and return the Lead.
  2. ILeadDestinationPort.dispatch(lead) and ILeadNotificationPort.notify(lead) in parallel. Neither may fail the request (dispatch must not throw; CreateLeadService also swallows throws).
  3. When dispatch returns { conversationId, contactId }, ILeadRepository.setChatwootRefs writes those ids. A persistence failure is logged and the HTTP response still succeeds — the lead row exists; the inbox Chatwoot link will be missing.
  4. 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​

MethodBehavior
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:

  1. Search contact by email (GET …/contacts/search).
  2. Create contact if missing (POST …/contacts), using whatsappNumber ?? phone.
  3. Create conversation in the configured inbox; additional_attributes.lead_id is the FlowPOS lead id.
  4. Label marketing-lead; post a private activity note with enriched fields.
  5. 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):

NameRole
CHATWOOT_BASE_URLChatwoot origin (also used to build the operator deep-link)
CHATWOOT_API_TOKENAPI access token
CHATWOOT_ACCOUNT_IDAccount id
CHATWOOT_MARKETING_INBOX_IDPreferred marketing inbox when set
CHATWOOT_INBOX_IDFallback 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 betaLEAD_NOTIFICATION_EMAIL
staging, localhost, developmentskip, 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:

ParamDefaultConstraints
page1Integer ≥ 1
size20Integer 1–100
searchunsetMax 100 chars; partial name / email / business_name
sourceunsetdemo | contact
localeunsetes | 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):

PathPage
/platform-billing/leadsSearch + table
/platform-billing/leads/:leadIdFull 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/:

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 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.