Skip to content

Webchat release — April 16-17, 2026

Reference doc for the backend+widget+CRM work merged on 2026-04-17. Captures what shipped, what's half-done, and non-obvious gotchas that bit us so we don't repeat them.

What shipped

Widget (dm-voice-agent/backend/public/webchat-widget.js)

  • Bot badge above each outbound bubble (Agent name + [Bot] pill).
  • Collapsible Answer / Sources ▾ block below the bubble with numbered links to KB documents.
  • Reply text is clean — links are no longer appended as \n\nLinks: in the body for channel.type === 'webchat'. They come through metadata.sources and are rendered as a structured list.
  • Email opt-in flow: yellow banner (🔔 Click to set your email…) above the compose box + modal with Set my email / Skip.
  • Smart trigger: banner only appears after the visitor has sent at least one message (or if history shows a prior session). It does not flash on empty chats.
  • Skip sets localStorage[dma-webchat-email-dismissed-<tenant>-<channel>] = 1 and the banner stays hidden forever for that device.

Backend (dm-voice-agent/backend)

  • New resolveStructuredSourceLinks() in src/services/n8n-ai.service.ts — same dedup/cap rules as the text version but returns [{id, title, url}].
  • src/services/message-processor.service.ts branches on channel.type: webchat channels get metadata.sources; other channels (Meta/Instagram) keep the legacy text-append behavior.
  • New POST /v1/webchat/contact (rate-limit 20/min/ip) in src/api/routes/webchat.ts — stores visitor email on the conversation profile.
  • contact_email returned by:
    • POST /v1/webchat/init (so returning visitors don't see the banner).
    • GET /v1/messages/conversations inside the participant object.
  • emailService.sendOperatorReplyNotification() in src/services/email.service.ts — HTML+text email template, XSS-safe escaping, SMTP via the existing nodemailer transport.
  • Trigger wired in src/api/routes/messages-send.ts: on every outbound human reply in a webchat channel with a contact_email present, send notification fire-and-forget (errors logged, never block the reply).

Database

  • Migration 20260416_add_contact_email_to_conversation_profiles:
    • contact_email VARCHAR(320) NULL
    • contact_email_set_at DATETIME(3) NULL Both nullable, additive, safe to roll forward.
  • ConversationProfile model in prisma/schema.prisma updated to match.

FAQ matcher (shipped later the same day)

message-processor.service.ts now short-circuits the LLM when the inbound text exactly matches a saved Q/A. Match order:

  1. agents.prompt_starter_question_text (main).
  2. agent_starter_questions (per-agent dynamic list).
  3. faqs (tenant + channel.workspace_id, active = true) — new.
  4. n8n / LLM fallback.

Normalization is trim().toLowerCase(). The outbound message carries metadata = { is_starter_answer: true, source: 'starter' | 'faq', faq_id? } so CRM / analytics can tell the source apart.

FAQ KB status + Re-vectorize

  • GET /v1/tenants/:tenant_id/faqs/kb-status{kb_id, status, is_stale, updated_at, faq_count, workspace_id}. is_stale = true when the KB has been in an in-progress state for >2 minutes.
  • POST /v1/tenants/:tenant_id/faqs/resync → admin-only, forces faqService.syncToKB() and returns the fresh status.
  • Admin UI: PredefinedAnswersFAQBlock now shows a status header (Vectorization status: [pill] · N active FAQs) with a Re-vectorize button (disabled while a fresh sync is running, enabled when ready / failed / stalled).
  • checkFaqLock() got a 2-minute staleness guard so crashed syncs don't lock out all future edits (see Gotcha 7 below).

CRM (dma-crm/frontend)

  • Conversation TypeScript interface gained participant.contact_email: string | null.
  • ConversationItem shows ✉ email under the header when present.
  • ConversationThread header shows the email as a clickable mailto: link next to the message count.

Known gaps / tech debt

Email notifications

  • No throttling. If an operator sends 5 messages back-to-back the visitor receives 5 emails. Add a debounce (suggested: no more than one notification per conversation per 10 minutes). Trigger sits in routes/messages-send.ts.
  • No deep-link in the email. The message says "reopen our chat on the website" but there's no link back. We don't persist the site's origin URL anywhere. To fix, capture document.referrer or window.location.href on the widget's init call and store it on ConversationProfile (origin_url). Then inject into the template.

restricted_countries

Shipped as a 30% feature. What exists:

  • Field on agents (Json @default("[]")).
  • PATCH endpoint + admin UI (ISO-2 country codes).
  • Widget-side: calls https://ipapi.co/json/ at init, hides container if match.

What's missing:

  • No server-side check. POST /v1/webchat/send and /init don't consult restricted_countries. Direct curl bypasses the feature in seconds. If this is a compliance requirement, add IP-geoip check at the route level before accepting messages.
  • Only webchat. Meta/Instagram/voice/WhatsApp inbound paths ignore the field entirely.
  • ipapi.co free tier = 1000 requests/day. When it saturates the check fails open (everyone gets through). Consider a self-hosted fallback (MaxMind GeoLite2 binary is free and offline).
  • No logging, no counters, no admin visibility of blocked attempts.
  • Trivially bypassable via VPN, JS-off, private mode.

Sources block

  • KB titles. The title shown in the Sources list is whatever is in knowledge_base.title (falls back to filename, then 'Source'). Some KB docs have generic titles like "FAQ" — fix is in the KB editor, not code.
  • Only fires for documents with both suggest_source_link = trueand web_url != null. If either is missing, the source silently doesn't render. No admin warning about this.

Widget UX from the original Crisp-style screenshots, not yet built

  • Topic/category pill under the bot reply (e.g. Travel Insurance).
  • Emoji picker in the compose box.
  • Dynamic quick-reply chips under each response (requires n8n prompt changes to return suggested_replies[]).
  • Delayed follow-up bubble ("Just in case you leave…") — needs a scheduler.

Gotchas — things that caught us

1. prisma db push vs migrate deploy

If a NOT NULL JSON column is added to a non-empty table via prisma db push, MySQL inserts empty string into existing rows, NOT the Prisma-side @default("[]"). Reading those rows later crashes with SyntaxError: Unexpected end of JSON input inside the Prisma runtime.

This hit us hard on 2026-04-16: 17 agents had restricted_countries = '' and their bots silently stopped replying. Prisma threw before the n8n call, the error wasn't caught, no fallback outbound message was created. The hotfix was:

sql
UPDATE agents SET restricted_countries = '[]'
WHERE restricted_countries IS NULL
   OR restricted_countries = ''
   OR LENGTH(restricted_countries) = 0;

Rule going forward: Use npx prisma migrate deploy with an explicit SQL migration for any prod schema change. db push is dev-only. If you must add a NOT NULL JSON column, write the migration with an explicit DEFAULT ('[]') (MySQL 8.0.13+) and backfill existing rows in the same statement.

2. Fastify JSON response serialization drops unknown keys

When a route response schema declares a field as { type: 'object' } with no properties and no additionalProperties, Fastify's fast-json-stringify serializes it as {}. This means anything you put into that object at runtime is silently lost before it reaches the client.

We hit this with metadata: { type: 'object' } in the webchat messages endpoints — message-processor wrote metadata.sources into the DB correctly, but the widget got metadata: {}. Fixed in 2053eb7 by changing every metadata schema to { type: 'object', additionalProperties: true }.

Rule going forward: For any schema field that should pass through free-form JSON (metadata, raw platform payloads, pass-through config), explicitly set additionalProperties: true. Assume fast-json-stringify will drop anything you don't name.

3. ensureProfile doesn't fire for webchat

conversationProfileService.ensureProfile() is called from message-processor.service.ts only when the inbound message is created inside the processor. For webchat, the inbound is created earlier in webchat.service.sendMessage() and passed in via existingInboundMessageId — so ensureProfile is never called for webchat.

That's why setContactEmail() does its own upsert on ConversationProfile rather than assuming a row exists. Keep this in mind if you add more webchat-specific profile fields.

4. dma-crm repo has long-lived dirty state

As of this release, dma-crm had ~30 uncommitted file changes (Campaigns, Calls, AccountSettings, CustomMenu, etc.) from parallel work that wasn't pushed to git. ./deploy-twinlix.sh builds from the local working tree, so running the deploy script ships those changes regardless of git state.

Rule going forward: Before ./deploy-twinlix.sh, check git status. If you see unexpected work, stop and ask. Committing before deploy is the safer workflow.

5. Webchat widget is vanilla JS at backend/public/webchat-widget.js

The embed widget client sites load is not the React admin preview in twinlix-admin-front/src/components/1-ions/ChatPane. Those are two completely separate codebases:

  • webchat-widget.js — vanilla JS, served as static asset from Fastify at https://api.twinlix.com/webchat-widget.js. This is what customers embed on their sites. No build step; edit the file directly.
  • ChatPane/… — React preview inside the admin, for configuration UI. Does not affect end users.

Don't "improve" the widget by refactoring to React. It's vanilla for a reason: no build, no bundle, loads with one <script> tag on any client site.

6. FAQ KB sync can get stuck, which 409s every FAQ edit

Every create/update/delete on the faqs table triggers faqService.syncToKB(), which flips the FAQ Knowledge Base row into parsing → parsed → vectorizing → ready. The vectorizing phase is handled by an n8n workflow that writes vectors somewhere external (ElevenLabs or similar) and is expected to patch the KB status back to ready when done. If that callback is lost, the KB stays in vectorizing forever and checkFaqLock() returns HTTP 409 on every subsequent FAQ edit — the admin UI looks bricked.

We hit this on 2026-04-17. Signs in DevTools: repeated 409 on PATCH /v1/tenants/…/faqs/<id>.

Unblock options, fastest first:

sql
UPDATE knowledge_bases
SET status = 'ready'
WHERE source_type = 'faq'
  AND status IN ('parsing', 'parsed', 'vectorizing')
  AND updated_at < NOW() - INTERVAL 2 MINUTE;

or call POST /v1/tenants/:id/faqs/resync, or click "Re-vectorize" in the admin UI. The checkFaqLock() staleness guard (2 min) also auto-unblocks edits — we added it so this can't strand a tenant forever.

Open question: why knowledge_chunks shows 0 rows even after vectorizing is set. Vectors apparently live in an external store, not in MySQL. If you're adding a new KB source_type, validate the n8n-side callback actually lands.

7. Fire-and-forget email errors must be swallowed

In routes/messages-send.ts the email notification is fired via void (async () => { ... })(). Any exception inside must be caught and logged — if it escapes, Node emits an unhandled rejection, which is a noisy way to lose the reply. The current implementation has a try/catch with logger.warn. Keep it that way for any future fire-and-forget side effects in hot request paths.


Deploy checklist (reference)

Backend:

ssh root@144.76.159.73
cd /opt/dm-voice-agent-twinlix
git pull origin main
cd backend
npm install
npx prisma migrate deploy      # only if prisma/migrations/ changed
npx prisma generate            # regenerate client types
npm run build                  # tsc → dist/
pm2 restart dm-voice-agent

CRM:

cd dma-crm
./deploy-twinlix.sh            # check git status first

Widget changes ship with the backend deploy — they're static assets out of backend/public/. No separate step, no cache busting needed (nginx serves with cache-control: max-age=0), but client browsers may cache the file — hard-refresh is required to pick up widget-only changes.

Twinlix platform documentation.