diff --git a/.gitignore b/.gitignore index f0e03a7..fea09d8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules dist docs -coverage \ No newline at end of file +coverage +package-lock.json diff --git a/package.json b/package.json index 0b83cd8..b481ab4 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,11 @@ "size-limit": [ { "path": "dist/utils.cjs.production.min.js", - "limit": "10 KB" + "limit": "13 KB" }, { "path": "dist/utils.esm.js", - "limit": "10 KB" + "limit": "13 KB" } ], "devDependencies": { diff --git a/src/index.ts b/src/index.ts index ca14ac0..28eba18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,12 @@ import { formatNumber, } from './helpers'; -import { toURL, isSameHost, isValidDomain } from './url'; +import { + toURL, + isSameHost, + isValidDomain, + extractFilenameFromUrl, +} from './url'; import { getRecipients } from './email'; @@ -42,6 +47,27 @@ import { getMaxUploadSizeByChannel, } from './fileUploadRules'; +import { + MEDIA_FORMATS, + COMPONENT_TYPES, + findComponentByType, + processVariable, + extractVariables, + renderTemplatePreview, + isSendableTemplate, + hasMediaHeader, + isDocumentHeader, + getMediaType, + buildWhatsAppProcessedParams, + isWhatsAppComplete, + isTwilioMediaTemplate, + getTwilioMediaUrl, + getTwilioMediaVariableKey, + buildTwilioProcessedParams, + isTwilioComplete, + applyTwilioMediaFilename, +} from './template'; + export { clamp, coerceToDate, @@ -68,6 +94,7 @@ export { toURL, isSameHost, isValidDomain, + extractFilenameFromUrl, trimContent, downloadFile, getFileInfo, @@ -75,4 +102,24 @@ export { formatNumber, getAllowedFileTypesByChannel, getMaxUploadSizeByChannel, + MEDIA_FORMATS, + COMPONENT_TYPES, + findComponentByType, + processVariable, + extractVariables, + renderTemplatePreview, + isSendableTemplate, + hasMediaHeader, + isDocumentHeader, + getMediaType, + buildWhatsAppProcessedParams, + isWhatsAppComplete, + isTwilioMediaTemplate, + getTwilioMediaUrl, + getTwilioMediaVariableKey, + buildTwilioProcessedParams, + isTwilioComplete, + applyTwilioMediaFilename, }; + +export * from './types/template'; diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..10f42e0 --- /dev/null +++ b/src/template.ts @@ -0,0 +1,281 @@ +import { extractFilenameFromUrl } from './url'; +import { + TemplateButtonParam, + TwilioContentTemplate, + TwilioProcessedParams, + WhatsAppMessageTemplate, + WhatsAppProcessedParams, + WhatsAppTemplateComponent, +} from './types/template'; + +// Header formats that carry a media attachment. +export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT']; + +export const COMPONENT_TYPES = { + HEADER: 'HEADER', + BODY: 'BODY', + BUTTONS: 'BUTTONS', +} as const; + +// Component types that can't be sent from the composer. +const UNSUPPORTED_COMPONENT_TYPES = [ + 'LIST', + 'PRODUCT', + 'CATALOG', + 'CALL_PERMISSION_REQUEST', +]; + +const TWILIO_MEDIA_TEMPLATE_TYPE = 'media'; +const VARIABLE_REGEX = /{{([^}]+)}}/g; + +export const findComponentByType = < + T extends WhatsAppTemplateComponent['type'] +>( + template: WhatsAppMessageTemplate, + type: T +): Extract | undefined => + template.components?.find(component => component.type === type) as + | Extract + | undefined; + +// Strips the surrounding braces from a `{{token}}` match. +export const processVariable = (str: string): string => + str.replace(/{{|}}/g, ''); + +const isCsatTemplate = (name: string): boolean => + name.startsWith('customer_satisfaction_survey'); + +// Ordered, de-duplicated list of variable tokens found in a body string. +export const extractVariables = (body: string): string[] => { + if (!body) return []; + const regex = new RegExp(VARIABLE_REGEX.source, 'g'); + const seen = new Set(); + const ordered: string[] = []; + let match: RegExpExecArray | null; + while ((match = regex.exec(body)) !== null) { + const key = match[1].trim(); + if (!seen.has(key)) { + seen.add(key); + ordered.push(key); + } + } + return ordered; +}; + +// Replaces `{{token}}` occurrences with values, keeping the token when unset. +export const renderTemplatePreview = ( + body: string, + values: Record +): string => { + if (!body) return ''; + return body.replace(VARIABLE_REGEX, (_match, rawKey) => { + const key = rawKey.trim(); + const value = values[key]; + return value && value.length > 0 ? value : `{{${key}}}`; + }); +}; + +/** + * Whether a WhatsApp template can be sent from the composer: + * - requires status + components + * - status (case-insensitive) === 'approved' + * - category !== 'AUTHENTICATION' + * - name does not start with 'customer_satisfaction_survey' + * - no LIST/PRODUCT/CATALOG/CALL_PERMISSION_REQUEST component and no LOCATION header + */ +export const isSendableTemplate = ( + template: WhatsAppMessageTemplate +): boolean => { + if (!template || !template.status || !template.components) return false; + if (template.status.toLowerCase() !== 'approved') return false; + if (template.category === 'AUTHENTICATION') return false; + if (template.name && isCsatTemplate(template.name)) return false; + + const hasUnsupportedComponents = template.components.some( + component => + UNSUPPORTED_COMPONENT_TYPES.indexOf(component.type) !== -1 || + (component.type === 'HEADER' && component.format === 'LOCATION') + ); + if (hasUnsupportedComponents) return false; + + return true; +}; + +export const hasMediaHeader = (template: WhatsAppMessageTemplate): boolean => { + const header = findComponentByType(template, 'HEADER'); + return header?.format ? MEDIA_FORMATS.indexOf(header.format) !== -1 : false; +}; + +export const isDocumentHeader = ( + template: WhatsAppMessageTemplate +): boolean => { + const header = findComponentByType(template, 'HEADER'); + return header?.format?.toLowerCase() === 'document'; +}; + +export const getMediaType = (template: WhatsAppMessageTemplate): string => { + const header = findComponentByType(template, 'HEADER'); + return header?.format ? header.format.toLowerCase() : ''; +}; + +const bodyHasVariables = (template: WhatsAppMessageTemplate): boolean => { + const body = findComponentByType(template, 'BODY'); + return body ? body.text.match(VARIABLE_REGEX) !== null : false; +}; + +// Collects the URL/COPY_CODE buttons that require a user-supplied parameter, +// preserving their positional index (sparse array). +const buildButtonParams = ( + template: WhatsAppMessageTemplate +): TemplateButtonParam[] | undefined => { + const buttonComponents = (template.components || []).filter( + component => component.type === 'BUTTONS' + ); + const buttons: TemplateButtonParam[] = []; + let found = false; + buttonComponents.forEach(component => { + if (component.type !== 'BUTTONS' || !component.buttons) return; + component.buttons.forEach((button, index) => { + if (button.type === 'URL' && button.url && button.url.includes('{{')) { + const buttonVars = button.url.match(VARIABLE_REGEX) || []; + if (buttonVars.length > 0) { + found = true; + buttons[index] = { + type: 'url', + parameter: '', + url: button.url, + variables: buttonVars.map(processVariable), + }; + } + } + if (button.type === 'COPY_CODE') { + found = true; + buttons[index] = { type: 'copy_code', parameter: '' }; + } + }); + }); + return found ? buttons : undefined; +}; + +/** + * Builds the empty WhatsApp processed_params scaffold for a template: body keys, + * a media header block, and the button parameters that need filling. + */ +export const buildWhatsAppProcessedParams = ( + template: WhatsAppMessageTemplate +): WhatsAppProcessedParams => { + const params: WhatsAppProcessedParams = {}; + + const body = findComponentByType(template, 'BODY'); + if (!body) return params; + + const matchedVariables = body.text.match(VARIABLE_REGEX); + if (matchedVariables) { + const bodyParams: Record = {}; + matchedVariables.forEach(variable => { + bodyParams[processVariable(variable)] = ''; + }); + params.body = bodyParams; + } + + if (hasMediaHeader(template)) { + const format = getMediaType(template); + params.header = { media_url: '', media_type: format }; + if (format === 'document') params.header.media_name = ''; + } + + const buttons = buildButtonParams(template); + if (buttons) params.buttons = buttons; + + return params; +}; + +/** + * Whether every required WhatsApp input has been filled. A template with no body + * variables and no media header is considered complete even if it has buttons + * (mirrors the composer's validation). + */ +export const isWhatsAppComplete = ( + template: WhatsAppMessageTemplate, + processedParams: WhatsAppProcessedParams +): boolean => { + const hasVariables = bodyHasVariables(template); + const media = hasMediaHeader(template); + + if (!hasVariables && !media) return true; + + if (media && !processedParams.header?.media_url) return false; + + if (hasVariables && processedParams.body) { + const hasEmptyBodyVariable = Object.keys(processedParams.body).some( + key => !processedParams.body?.[key] + ); + if (hasEmptyBodyVariable) return false; + } + + if (processedParams.buttons) { + const hasEmptyButtonParameter = processedParams.buttons.some( + button => button && !button.parameter + ); + if (hasEmptyButtonParameter) return false; + } + + return true; +}; + +export const isTwilioMediaTemplate = ( + template: TwilioContentTemplate +): boolean => template.template_type === TWILIO_MEDIA_TEMPLATE_TYPE; + +export const getTwilioMediaUrl = (template: TwilioContentTemplate): string => + template.types?.['twilio/media']?.media?.[0] ?? ''; + +// The variable token (e.g. '1') embedded in a Twilio media URL, if any. +export const getTwilioMediaVariableKey = ( + template: TwilioContentTemplate +): string | null => { + if (!isTwilioMediaTemplate(template)) return null; + const mediaUrl = getTwilioMediaUrl(template); + if (!mediaUrl) return null; + return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null; +}; + +// Builds the empty Twilio processed_params: one key per body variable plus the +// media variable when present. +export const buildTwilioProcessedParams = ( + template: TwilioContentTemplate +): TwilioProcessedParams => { + const params: TwilioProcessedParams = {}; + extractVariables(template.body || '').forEach(variable => { + params[variable] = ''; + }); + const mediaKey = getTwilioMediaVariableKey(template); + if (mediaKey) params[mediaKey] = ''; + return params; +}; + +export const isTwilioComplete = ( + template: TwilioContentTemplate, + processedParams: TwilioProcessedParams +): boolean => { + const variables = extractVariables(template.body || ''); + const mediaKey = getTwilioMediaVariableKey(template); + + if (variables.length === 0 && !mediaKey) return true; + if (variables.some(variable => !processedParams[variable])) return false; + if (mediaKey && !processedParams[mediaKey]) return false; + return true; +}; + +// Reduces the Twilio media variable value to a filename before sending. +export const applyTwilioMediaFilename = ( + template: TwilioContentTemplate, + processedParams: TwilioProcessedParams +): TwilioProcessedParams => { + const mediaKey = getTwilioMediaVariableKey(template); + const result = { ...processedParams }; + if (mediaKey && result[mediaKey]) { + result[mediaKey] = extractFilenameFromUrl(result[mediaKey]); + } + return result; +}; diff --git a/src/types/template.ts b/src/types/template.ts new file mode 100644 index 0000000..972d2e9 --- /dev/null +++ b/src/types/template.ts @@ -0,0 +1,80 @@ +// Neutral WhatsApp/Twilio template types shared by web and mobile. +// Shapes follow the backend API contract: raw templates in, processed_params out. + +export type WhatsAppTemplateHeaderFormat = + | 'TEXT' + | 'IMAGE' + | 'VIDEO' + | 'DOCUMENT' + | 'LOCATION'; + +export type WhatsAppTemplateButton = { + type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER' | 'COPY_CODE' | string; + text?: string; + url?: string; + phone_number?: string; + example?: string[]; +}; + +export type WhatsAppTemplateComponent = + | { + type: 'HEADER'; + format?: WhatsAppTemplateHeaderFormat; + text?: string; + example?: Record; + } + | { type: 'BODY'; text: string; example?: Record } + | { type: 'FOOTER'; text: string } + | { type: 'BUTTONS'; buttons: WhatsAppTemplateButton[] }; + +export interface WhatsAppMessageTemplate { + id?: string; + name: string; + status: string; + category: string; + language: string; + namespace?: string; + components: WhatsAppTemplateComponent[]; + parameter_format?: 'POSITIONAL' | 'NAMED'; +} + +export interface TwilioContentTemplate { + content_sid: string; + friendly_name: string; + language: string; + category?: string; + status: string; + template_type?: string; + media_type?: string; + body: string; + variables?: Record; + types?: Record>; +} + +export interface TwilioContentTemplates { + templates?: TwilioContentTemplate[]; +} + +// A single WhatsApp button parameter inside processed_params.buttons. +export interface TemplateButtonParam { + type: 'url' | 'copy_code'; + parameter: string; + url?: string; + variables?: string[]; +} + +// WhatsApp processed_params: nested body / header / buttons. +export interface WhatsAppProcessedParams { + body?: Record; + header?: { + media_url: string; + media_type: string; + media_name?: string; + }; + buttons?: TemplateButtonParam[]; +} + +// Twilio processed_params: a flat map keyed by variable token. +export type TwilioProcessedParams = Record; + +export type ProcessedParams = WhatsAppProcessedParams | TwilioProcessedParams; diff --git a/src/url.ts b/src/url.ts index 91aa012..e85056b 100644 --- a/src/url.ts +++ b/src/url.ts @@ -51,6 +51,26 @@ export const isSameHost = ( } }; +/** + * Extracts a filename from a URL. + * Falls back to a regex match (and finally the original string) when the URL + * cannot be parsed by the `URL` constructor. + * + * @param {string} url - URL to extract the filename from. + * @returns {string} The filename, or the original input if none can be derived. + */ +export const extractFilenameFromUrl = (url: string): string => { + if (!url || typeof url !== 'string') return url; + try { + const urlObj = new URL(url); + const filename = urlObj.pathname.split('/').pop(); + return filename || url; + } catch { + const match = url.match(/\/([^/?#]+)(?:[?#]|$)/); + return match ? match[1] : url; + } +}; + /** * Check if a string is a valid domain name. * An empty string is allowed and considered valid. diff --git a/test/template.test.ts b/test/template.test.ts new file mode 100644 index 0000000..5dab8dc --- /dev/null +++ b/test/template.test.ts @@ -0,0 +1,263 @@ +import { + MEDIA_FORMATS, + findComponentByType, + processVariable, + extractVariables, + renderTemplatePreview, + isSendableTemplate, + hasMediaHeader, + isDocumentHeader, + getMediaType, + buildWhatsAppProcessedParams, + isWhatsAppComplete, + isTwilioMediaTemplate, + getTwilioMediaUrl, + getTwilioMediaVariableKey, + buildTwilioProcessedParams, + isTwilioComplete, + applyTwilioMediaFilename, +} from '../src/template'; +import { + WhatsAppMessageTemplate, + TwilioContentTemplate, +} from '../src/types/template'; + +const whatsAppTemplate = ( + overrides: Partial = {} +): WhatsAppMessageTemplate => ({ + name: 'order_update', + status: 'approved', + category: 'MARKETING', + language: 'en', + components: [{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} shipped.' }], + ...overrides, +}); + +const twilioTemplate = ( + overrides: Partial = {} +): TwilioContentTemplate => ({ + content_sid: 'HX1', + friendly_name: 'media_demo', + language: 'en', + status: 'approved', + template_type: 'media', + body: 'Hi {{1}}', + types: { 'twilio/media': { media: ['https://x.com/{{2}}'] } }, + ...overrides, +}); + +describe('#processVariable / #extractVariables', () => { + it('strips braces', () => { + expect(processVariable('{{contact.name}}')).toBe('contact.name'); + }); + + it('returns ordered, de-duplicated variable keys', () => { + expect( + extractVariables('Hi {{name}}, again {{name}} and {{ order }}') + ).toEqual(['name', 'order']); + expect(extractVariables('')).toEqual([]); + }); +}); + +describe('#renderTemplatePreview', () => { + it('fills values and keeps placeholders for missing ones', () => { + expect( + renderTemplatePreview('Hi {{name}}, order {{id}}', { name: 'Sam' }) + ).toBe('Hi Sam, order {{id}}'); + }); +}); + +describe('#findComponentByType', () => { + it('finds a component by its type', () => { + const template = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'IMAGE' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(findComponentByType(template, 'HEADER')?.type).toBe('HEADER'); + expect(findComponentByType(template, 'BUTTONS')).toBeUndefined(); + }); +}); + +describe('#isSendableTemplate', () => { + it('accepts an approved, supported template (case-insensitive)', () => { + expect(isSendableTemplate(whatsAppTemplate())).toBe(true); + expect(isSendableTemplate(whatsAppTemplate({ status: 'APPROVED' }))).toBe( + true + ); + }); + + it('rejects non-approved, authentication, csat and unsupported templates', () => { + expect(isSendableTemplate(whatsAppTemplate({ status: 'PENDING' }))).toBe( + false + ); + expect( + isSendableTemplate(whatsAppTemplate({ category: 'AUTHENTICATION' })) + ).toBe(false); + expect( + isSendableTemplate( + whatsAppTemplate({ name: 'customer_satisfaction_survey_1' }) + ) + ).toBe(false); + expect( + isSendableTemplate( + whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'LOCATION' }, + { type: 'BODY', text: 'hi' }, + ], + }) + ) + ).toBe(false); + }); +}); + +describe('#hasMediaHeader / #getMediaType / #isDocumentHeader', () => { + it('detects media headers and their type', () => { + const image = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'IMAGE' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(MEDIA_FORMATS).toContain('IMAGE'); + expect(hasMediaHeader(image)).toBe(true); + expect(getMediaType(image)).toBe('image'); + expect(isDocumentHeader(image)).toBe(false); + + const text = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'TEXT', text: 'Hello' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(hasMediaHeader(text)).toBe(false); + }); + + it('flags document headers', () => { + const doc = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'DOCUMENT' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(isDocumentHeader(doc)).toBe(true); + }); +}); + +describe('#buildWhatsAppProcessedParams', () => { + it('builds the empty scaffold with body, media header and sparse buttons', () => { + const params = buildWhatsAppProcessedParams( + whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'DOCUMENT' }, + { type: 'BODY', text: 'Hi {{1}}' }, + { + type: 'BUTTONS', + buttons: [ + { type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' }, + { type: 'COPY_CODE', text: 'Copy' }, + { type: 'QUICK_REPLY', text: 'Yes' }, + ], + }, + ], + }) + ); + expect(params).toEqual({ + body: { '1': '' }, + header: { media_url: '', media_type: 'document', media_name: '' }, + buttons: [ + { + type: 'url', + parameter: '', + url: 'https://x.com/{{1}}', + variables: ['1'], + }, + { type: 'copy_code', parameter: '' }, + ], + }); + }); + + it('returns an empty object when there is no body component', () => { + expect( + buildWhatsAppProcessedParams(whatsAppTemplate({ components: [] })) + ).toEqual({}); + }); +}); + +describe('#isWhatsAppComplete', () => { + const template = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'IMAGE' }, + { type: 'BODY', text: 'Hi {{1}}' }, + { type: 'BUTTONS', buttons: [{ type: 'COPY_CODE', text: 'Copy' }] }, + ], + }); + + it('requires body, media and button values', () => { + const params = buildWhatsAppProcessedParams(template); + expect(isWhatsAppComplete(template, params)).toBe(false); + params.body!['1'] = 'Sam'; + params.header!.media_url = 'https://x.com/a.png'; + params.buttons![0].parameter = 'CODE'; + expect(isWhatsAppComplete(template, params)).toBe(true); + }); + + it('is complete when there are no variables and no media header', () => { + const plain = whatsAppTemplate({ + components: [{ type: 'BODY', text: 'Thanks for reaching out.' }], + }); + expect(isWhatsAppComplete(plain, buildWhatsAppProcessedParams(plain))).toBe( + true + ); + }); +}); + +describe('twilio helpers', () => { + it('identifies media templates and their media variable key', () => { + const template = twilioTemplate(); + expect(isTwilioMediaTemplate(template)).toBe(true); + expect(getTwilioMediaUrl(template)).toBe('https://x.com/{{2}}'); + expect(getTwilioMediaVariableKey(template)).toBe('2'); + expect( + getTwilioMediaVariableKey(twilioTemplate({ template_type: 'text' })) + ).toBeNull(); + }); + + it('builds the empty processed_params for body + media variables', () => { + expect(buildTwilioProcessedParams(twilioTemplate())).toEqual({ + '1': '', + '2': '', + }); + }); + + it('validates completeness', () => { + const template = twilioTemplate(); + const params = buildTwilioProcessedParams(template); + expect(isTwilioComplete(template, params)).toBe(false); + params['1'] = 'Sam'; + params['2'] = 'https://x.com/photo.png'; + expect(isTwilioComplete(template, params)).toBe(true); + }); + + it('is complete when there are no variables and no media', () => { + const plain = twilioTemplate({ + template_type: 'text', + body: 'hello', + types: {}, + }); + expect(isTwilioComplete(plain, buildTwilioProcessedParams(plain))).toBe( + true + ); + }); + + it('reduces the media variable value to a filename on send', () => { + const template = twilioTemplate(); + const params = { '1': 'Sam', '2': 'https://x.com/path/photo.png?token=1' }; + expect(applyTwilioMediaFilename(template, params)).toEqual({ + '1': 'Sam', + '2': 'photo.png', + }); + }); +});