This PR enables sending CSAT surveys on WhatsApp using approved WhatsApp message templates, ensuring survey delivery even after the 24-hour session window. The system now automatically creates, updates, and monitors WhatsApp CSAT templates without manual intervention. <img width="1664" height="1792" alt="approved" src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975" /> #### Why this change Previously, WhatsApp CSAT messages failed outside the 24-hour customer window. With this update: - CSAT surveys are delivered reliably using WhatsApp templates - Template creation happens automatically in the background - Users can modify survey content and recreate templates easily - Clear UI states show template approval status #### Screens & States <details> <summary>Default — No template configured yet</summary> <img width="1662" height="1788" alt="default" src="https://github.com/user-attachments/assets/ed26d71b-cf7c-4a26-a2af-da88772c847c" /> </details> <details> <summary>Pending — Template submitted, awaiting Meta approval</summary> <img width="1658" height="1816" alt="pending" src="https://github.com/user-attachments/assets/923b789b-d91b-4364-905d-e56a2b65331a" /> </details> <details> <summary>Approved — Survey will be sent when conversation resolves</summary> <img width="1664" height="1792" alt="approved" src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975" /> </details> <details> <summary>Rejected — Template rejected by Meta</summary> <img width="1672" height="1776" alt="rejected" src="https://github.com/user-attachments/assets/f69a9b0e-be27-4e67-a993-7b8149502c4f" /> </details> <details> <summary>Not Found — Template missing in Meta Platform</summary> <img width="1660" height="1784" alt="not-exist" src="https://github.com/user-attachments/assets/a2a4b4f7-b01a-4424-8fcb-3ed84256e057" /> </details> <details> <summary>Edit Template — Delete & recreate template on change</summary> <img width="2342" height="1778" alt="edit-survey" src="https://github.com/user-attachments/assets/0f999285-0341-4226-84e9-31f0c6446924" /> </details> #### Test Cases **1. First-time CSAT setup on WhatsApp inbox** - Enable CSAT - Enter message + button text - Save - Expected: Template created automatically, UI shows pending state **2. CSAT toggle without changing text** - Existing approved template - Toggle CSAT OFF → ON (no text change) - Expected: No confirmation alert, no template recreation **3. Editing only survey rules** - Modify labels or rule conditions only - Expected: No confirmation alert, template remains unchanged **4. Template text change** - Change survey message or button text - Save - Expected: - Confirmation dialog shown - On confirm → previous template deleted, new one created - On cancel → revert to previous values **5. Language change** - Change template language (e.g., en → es) - Expected: Confirmation dialog + new template on confirm **6. Sending survey** - Template approved → always send template - Template pending → send free-form within 24 hours only - Template rejected/missing → fallback to free-form (if within window) - Outside 24 hours & no approved template → activity log only **7. Non-WhatsApp inbox** - Enable CSAT for email/web inbox - Expected: No template logic triggered Fixes https://linear.app/chatwoot/issue/CW-6188/support-for-sending-csat-surveys-via-approved-whatsapp --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
264 lines
6.1 KiB
JavaScript
264 lines
6.1 KiB
JavaScript
// Formatting rules for different contexts (channels and special contexts)
|
|
// marks: inline formatting (strong, em, code, link, strike)
|
|
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
|
|
export const FORMATTING = {
|
|
// Channel formatting
|
|
'Channel::Email': {
|
|
marks: ['strong', 'em', 'code', 'link'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
|
menu: [
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'link',
|
|
'bulletList',
|
|
'orderedList',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::WebWidget': {
|
|
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
|
menu: [
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'link',
|
|
'strike',
|
|
'bulletList',
|
|
'orderedList',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::Api': {
|
|
marks: ['strong', 'em'],
|
|
nodes: [],
|
|
menu: ['strong', 'em', 'undo', 'redo'],
|
|
},
|
|
'Channel::FacebookPage': {
|
|
marks: ['strong', 'em', 'code', 'strike'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
|
menu: [
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'strike',
|
|
'bulletList',
|
|
'orderedList',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::TwitterProfile': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
'Channel::TwilioSms': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
'Channel::Sms': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
'Channel::Whatsapp': {
|
|
marks: ['strong', 'em', 'code', 'strike'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
|
menu: [
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'strike',
|
|
'bulletList',
|
|
'orderedList',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::Line': {
|
|
marks: ['strong', 'em', 'code', 'strike'],
|
|
nodes: ['codeBlock'],
|
|
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
|
},
|
|
'Channel::Telegram': {
|
|
marks: ['strong', 'em', 'link', 'code'],
|
|
nodes: [],
|
|
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
|
|
},
|
|
'Channel::Instagram': {
|
|
marks: ['strong', 'em', 'code', 'strike'],
|
|
nodes: ['bulletList', 'orderedList'],
|
|
menu: [
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'bulletList',
|
|
'orderedList',
|
|
'strike',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Channel::Voice': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
'Channel::Tiktok': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
// Special contexts (not actual channels)
|
|
'Context::Default': {
|
|
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
|
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
|
menu: [
|
|
'strong',
|
|
'em',
|
|
'code',
|
|
'link',
|
|
'strike',
|
|
'bulletList',
|
|
'orderedList',
|
|
'undo',
|
|
'redo',
|
|
],
|
|
},
|
|
'Context::MessageSignature': {
|
|
marks: ['strong', 'em', 'link'],
|
|
nodes: ['image'],
|
|
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
|
|
},
|
|
'Context::InboxSettings': {
|
|
marks: ['strong', 'em', 'link'],
|
|
nodes: [],
|
|
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
|
},
|
|
'Context::Plain': {
|
|
marks: [],
|
|
nodes: [],
|
|
menu: [],
|
|
},
|
|
};
|
|
|
|
// Editor menu options for Full Editor
|
|
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
|
'strong',
|
|
'em',
|
|
'link',
|
|
'undo',
|
|
'redo',
|
|
'bulletList',
|
|
'orderedList',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'imageUpload',
|
|
'code',
|
|
];
|
|
|
|
/**
|
|
* Markdown formatting patterns for stripping unsupported formatting.
|
|
*
|
|
* Maps camelCase type names to ProseMirror snake_case schema names.
|
|
* Order matters: codeBlock before code to avoid partial matches.
|
|
*/
|
|
export const MARKDOWN_PATTERNS = [
|
|
// --- BLOCK NODES ---
|
|
{
|
|
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
|
|
patterns: [
|
|
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
|
|
],
|
|
},
|
|
{
|
|
type: 'blockquote', // PM: blockquote, eg: > quote
|
|
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'bulletList', // PM: bullet_list, eg: - item
|
|
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'orderedList', // PM: ordered_list, eg: 1. item
|
|
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'heading', // PM: heading, eg: ## Heading
|
|
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
|
|
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
|
|
},
|
|
{
|
|
type: 'image', // PM: image, eg: 
|
|
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
|
|
},
|
|
{
|
|
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
|
|
patterns: [
|
|
{ pattern: /\\\n/g, replacement: '\n' },
|
|
{ pattern: / {2,}\n/g, replacement: '\n' },
|
|
],
|
|
},
|
|
// --- INLINE MARKS ---
|
|
{
|
|
type: 'strong', // PM: strong, eg: **bold** or __bold__
|
|
patterns: [
|
|
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
|
|
{ pattern: /__(.+?)__/g, replacement: '$1' },
|
|
],
|
|
},
|
|
{
|
|
type: 'em', // PM: em, eg: *italic* or _italic_
|
|
patterns: [
|
|
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
|
// Match _text_ only at word boundaries (whitespace/string start/end)
|
|
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
|
{
|
|
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
|
replacement: '$1',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: 'strike', // PM: strike, eg: ~~strikethrough~~
|
|
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
|
|
},
|
|
{
|
|
type: 'code', // PM: code, eg: `inline code`
|
|
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
|
|
},
|
|
{
|
|
type: 'link', // PM: link, eg: [text](url)
|
|
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
|
|
},
|
|
];
|
|
|
|
// Editor image resize options for Message Editor
|
|
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
|
{
|
|
name: 'Small',
|
|
height: '24px',
|
|
},
|
|
{
|
|
name: 'Medium',
|
|
height: '48px',
|
|
},
|
|
{
|
|
name: 'Large',
|
|
height: '72px',
|
|
},
|
|
{
|
|
name: 'Original Size',
|
|
height: 'auto',
|
|
},
|
|
];
|