@ganju/utils 0.0.18 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/constants.d.ts +18 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +50 -3
  4. package/dist/crypto.d.ts +2 -2
  5. package/dist/crypto.d.ts.map +1 -1
  6. package/dist/crypto.js +1 -0
  7. package/dist/data.d.ts +6 -4
  8. package/dist/data.d.ts.map +1 -1
  9. package/dist/data.js +13 -3
  10. package/dist/dataApi.d.ts +54 -8
  11. package/dist/dataApi.d.ts.map +1 -1
  12. package/dist/dataApi.js +40 -7
  13. package/dist/dataAttachment.d.ts +8 -0
  14. package/dist/dataAttachment.d.ts.map +1 -0
  15. package/dist/dataAttachment.js +19 -0
  16. package/dist/dataCompiler.d.ts +3 -0
  17. package/dist/dataCompiler.d.ts.map +1 -1
  18. package/dist/dataCompiler.js +25 -1
  19. package/dist/dataDdl.d.ts.map +1 -1
  20. package/dist/dataDdl.js +5 -0
  21. package/dist/dataEngine.d.ts +9 -3
  22. package/dist/dataEngine.d.ts.map +1 -1
  23. package/dist/dataEngine.js +141 -5
  24. package/dist/dataIndexAdvice.d.ts +36 -0
  25. package/dist/dataIndexAdvice.d.ts.map +1 -0
  26. package/dist/dataIndexAdvice.js +89 -0
  27. package/dist/dataRpc.d.ts +12 -1
  28. package/dist/dataRpc.d.ts.map +1 -1
  29. package/dist/dataTools.js +3 -3
  30. package/dist/dataTriggers.d.ts +23 -0
  31. package/dist/dataTriggers.d.ts.map +1 -1
  32. package/dist/dataTriggers.js +46 -2
  33. package/dist/dataTypes.d.ts +46 -0
  34. package/dist/dataTypes.d.ts.map +1 -1
  35. package/dist/dataTypes.js +7 -1
  36. package/dist/index.d.ts +138 -4
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +31 -0
  39. package/dist/localizeDataError.d.ts.map +1 -1
  40. package/dist/localizeDataError.js +64 -2
  41. package/dist/schema.d.ts +85 -2
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +94 -1
  44. package/dist/sdkConstants.d.ts +1 -0
  45. package/dist/sdkConstants.d.ts.map +1 -1
  46. package/dist/sdkConstants.js +2 -1
  47. package/dist/whatsappTemplates.d.ts +77 -0
  48. package/dist/whatsappTemplates.d.ts.map +1 -0
  49. package/dist/whatsappTemplates.js +402 -0
  50. package/dist/whatsappWebhook.d.ts +28 -0
  51. package/dist/whatsappWebhook.d.ts.map +1 -0
  52. package/dist/whatsappWebhook.js +30 -0
  53. package/package.json +1 -1
  54. package/src/constants.ts +57 -3
  55. package/src/crypto.ts +3 -2
  56. package/src/data.ts +14 -0
  57. package/src/dataApi.ts +44 -6
  58. package/src/dataAttachment.ts +23 -0
  59. package/src/dataCompiler.ts +37 -0
  60. package/src/dataDdl.ts +7 -0
  61. package/src/dataEngine.ts +195 -5
  62. package/src/dataIndexAdvice.ts +127 -0
  63. package/src/dataRpc.ts +17 -1
  64. package/src/dataTools.ts +3 -3
  65. package/src/dataTriggers.ts +71 -1
  66. package/src/dataTypes.ts +62 -0
  67. package/src/index.ts +74 -1
  68. package/src/localizeDataError.ts +101 -3
  69. package/src/schema.ts +115 -1
  70. package/src/sdkConstants.ts +1 -0
  71. package/src/whatsappTemplates.ts +465 -0
  72. package/src/whatsappWebhook.ts +55 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whatsappTemplates.d.ts","sourceRoot":"","sources":["../src/whatsappTemplates.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,402 @@
1
+ "use strict";
2
+ // WhatsApp message templates: the shape Meta's Graph API takes and returns,
3
+ // and the rules it rejects templates for most often, checked before one is
4
+ // sent for review. Imports nothing, so the dashboard runs the same checks.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.whatsappSendError = exports.cleanWhatsappTemplateParam = exports.fillWhatsappTemplate = exports.whatsappTemplateSendable = exports.whatsappTemplateContent = exports.whatsappTemplateComponents = exports.whatsappTemplateIssueText = exports.checkWhatsappTemplate = exports.whatsappTemplateVariableCount = exports.whatsappTemplateVariables = exports.WHATSAPP_TEMPLATE_LANGUAGES = exports.WHATSAPP_TEMPLATE_EXAMPLE_MAX = exports.WHATSAPP_TEMPLATE_LABEL_MAX = exports.WHATSAPP_TEMPLATE_VARIABLES_MAX = exports.WHATSAPP_TEMPLATE_BUTTONS_MAX = exports.WHATSAPP_TEMPLATE_BUTTON_MAX = exports.WHATSAPP_TEMPLATE_FOOTER_MAX = exports.WHATSAPP_TEMPLATE_HEADER_MAX = exports.WHATSAPP_TEMPLATE_BODY_MAX = exports.WHATSAPP_TEMPLATE_NAME_MAX = exports.WHATSAPP_TEMPLATE_STATUSES_STOPPED = exports.WHATSAPP_TEMPLATE_STATUSES_WAITING = exports.WHATSAPP_TEMPLATE_STATUS_APPROVED = exports.WHATSAPP_TEMPLATE_CATEGORIES = void 0;
7
+ exports.WHATSAPP_TEMPLATE_CATEGORIES = ['UTILITY', 'MARKETING'];
8
+ // What Meta reports. Only APPROVED templates can be sent.
9
+ exports.WHATSAPP_TEMPLATE_STATUS_APPROVED = 'APPROVED';
10
+ // Still in Meta's hands: the hourly sync looks at these again.
11
+ exports.WHATSAPP_TEMPLATE_STATUSES_WAITING = [
12
+ 'PENDING',
13
+ 'IN_APPEAL',
14
+ 'PENDING_DELETION'
15
+ ];
16
+ // An approved template Meta stopped: its owners are told.
17
+ exports.WHATSAPP_TEMPLATE_STATUSES_STOPPED = ['PAUSED', 'DISABLED'];
18
+ exports.WHATSAPP_TEMPLATE_NAME_MAX = 512;
19
+ exports.WHATSAPP_TEMPLATE_BODY_MAX = 1024;
20
+ exports.WHATSAPP_TEMPLATE_HEADER_MAX = 60;
21
+ exports.WHATSAPP_TEMPLATE_FOOTER_MAX = 60;
22
+ exports.WHATSAPP_TEMPLATE_BUTTON_MAX = 25;
23
+ exports.WHATSAPP_TEMPLATE_BUTTONS_MAX = 10;
24
+ exports.WHATSAPP_TEMPLATE_VARIABLES_MAX = 20;
25
+ exports.WHATSAPP_TEMPLATE_LABEL_MAX = 40;
26
+ exports.WHATSAPP_TEMPLATE_EXAMPLE_MAX = 200;
27
+ const NAME_PATTERN = /^[a-z0-9_]+$/;
28
+ // Meta's template languages. The first ones are the ones most owners use.
29
+ exports.WHATSAPP_TEMPLATE_LANGUAGES = [
30
+ { code: 'es', name: 'Spanish' },
31
+ { code: 'es_MX', name: 'Spanish (Mexico)' },
32
+ { code: 'es_AR', name: 'Spanish (Argentina)' },
33
+ { code: 'es_ES', name: 'Spanish (Spain)' },
34
+ { code: 'en', name: 'English' },
35
+ { code: 'en_US', name: 'English (US)' },
36
+ { code: 'en_GB', name: 'English (UK)' },
37
+ { code: 'pt_BR', name: 'Portuguese (Brazil)' },
38
+ { code: 'pt_PT', name: 'Portuguese (Portugal)' },
39
+ { code: 'af', name: 'Afrikaans' },
40
+ { code: 'sq', name: 'Albanian' },
41
+ { code: 'ar', name: 'Arabic' },
42
+ { code: 'az', name: 'Azerbaijani' },
43
+ { code: 'bn', name: 'Bengali' },
44
+ { code: 'bg', name: 'Bulgarian' },
45
+ { code: 'ca', name: 'Catalan' },
46
+ { code: 'zh_CN', name: 'Chinese (China)' },
47
+ { code: 'zh_HK', name: 'Chinese (Hong Kong)' },
48
+ { code: 'zh_TW', name: 'Chinese (Taiwan)' },
49
+ { code: 'hr', name: 'Croatian' },
50
+ { code: 'cs', name: 'Czech' },
51
+ { code: 'da', name: 'Danish' },
52
+ { code: 'nl', name: 'Dutch' },
53
+ { code: 'et', name: 'Estonian' },
54
+ { code: 'fil', name: 'Filipino' },
55
+ { code: 'fi', name: 'Finnish' },
56
+ { code: 'fr', name: 'French' },
57
+ { code: 'ka', name: 'Georgian' },
58
+ { code: 'de', name: 'German' },
59
+ { code: 'el', name: 'Greek' },
60
+ { code: 'gu', name: 'Gujarati' },
61
+ { code: 'ha', name: 'Hausa' },
62
+ { code: 'he', name: 'Hebrew' },
63
+ { code: 'hi', name: 'Hindi' },
64
+ { code: 'hu', name: 'Hungarian' },
65
+ { code: 'id', name: 'Indonesian' },
66
+ { code: 'ga', name: 'Irish' },
67
+ { code: 'it', name: 'Italian' },
68
+ { code: 'ja', name: 'Japanese' },
69
+ { code: 'kn', name: 'Kannada' },
70
+ { code: 'kk', name: 'Kazakh' },
71
+ { code: 'rw_RW', name: 'Kinyarwanda' },
72
+ { code: 'ko', name: 'Korean' },
73
+ { code: 'ky_KG', name: 'Kyrgyz' },
74
+ { code: 'lo', name: 'Lao' },
75
+ { code: 'lv', name: 'Latvian' },
76
+ { code: 'lt', name: 'Lithuanian' },
77
+ { code: 'mk', name: 'Macedonian' },
78
+ { code: 'ms', name: 'Malay' },
79
+ { code: 'ml', name: 'Malayalam' },
80
+ { code: 'mr', name: 'Marathi' },
81
+ { code: 'nb', name: 'Norwegian' },
82
+ { code: 'fa', name: 'Persian' },
83
+ { code: 'pl', name: 'Polish' },
84
+ { code: 'pa', name: 'Punjabi' },
85
+ { code: 'ro', name: 'Romanian' },
86
+ { code: 'ru', name: 'Russian' },
87
+ { code: 'sr', name: 'Serbian' },
88
+ { code: 'sk', name: 'Slovak' },
89
+ { code: 'sl', name: 'Slovenian' },
90
+ { code: 'sw', name: 'Swahili' },
91
+ { code: 'sv', name: 'Swedish' },
92
+ { code: 'ta', name: 'Tamil' },
93
+ { code: 'te', name: 'Telugu' },
94
+ { code: 'th', name: 'Thai' },
95
+ { code: 'tr', name: 'Turkish' },
96
+ { code: 'uk', name: 'Ukrainian' },
97
+ { code: 'ur', name: 'Urdu' },
98
+ { code: 'uz', name: 'Uzbek' },
99
+ { code: 'vi', name: 'Vietnamese' },
100
+ { code: 'zu', name: 'Zulu' }
101
+ ];
102
+ const VARIABLE = /\{\{\s*(\d+)\s*\}\}/g;
103
+ /** Each {{n}} in the text, in the order they appear. */
104
+ const whatsappTemplateVariables = (text) => Array.from(text.matchAll(VARIABLE), match => Number(match[1]));
105
+ exports.whatsappTemplateVariables = whatsappTemplateVariables;
106
+ /** How many variables the body has: its highest {{n}}. */
107
+ const whatsappTemplateVariableCount = (body) => Math.max(0, ...(0, exports.whatsappTemplateVariables)(body));
108
+ exports.whatsappTemplateVariableCount = whatsappTemplateVariableCount;
109
+ const words = (text) => text
110
+ .replace(VARIABLE, ' ')
111
+ .split(/\s+/)
112
+ .filter(word => /[\p{L}\p{N}]/u.test(word)).length;
113
+ /**
114
+ * The rules Meta rejects templates for most often, checked before sending
115
+ * one for review. Meta's review has the last word; this only catches the
116
+ * common mistakes early.
117
+ */
118
+ const checkWhatsappTemplate = (draft) => {
119
+ const issues = [];
120
+ const add = (code, n) => issues.push(n === undefined ? { code } : { code, n });
121
+ if (!NAME_PATTERN.test(draft.name))
122
+ add('name_format');
123
+ if (draft.name.length > exports.WHATSAPP_TEMPLATE_NAME_MAX)
124
+ add('name_long');
125
+ if (!exports.WHATSAPP_TEMPLATE_LANGUAGES.some(l => l.code === draft.language)) {
126
+ add('language');
127
+ }
128
+ if (!exports.WHATSAPP_TEMPLATE_CATEGORIES.includes(draft.category))
129
+ add('category');
130
+ const header = draft.header?.trim() ?? '';
131
+ if (header.length > exports.WHATSAPP_TEMPLATE_HEADER_MAX)
132
+ add('header_long');
133
+ if ((0, exports.whatsappTemplateVariables)(header).length > 0)
134
+ add('header_variables');
135
+ if (/[\n\r]/.test(header))
136
+ add('header_lines');
137
+ const footer = draft.footer?.trim() ?? '';
138
+ if (footer.length > exports.WHATSAPP_TEMPLATE_FOOTER_MAX)
139
+ add('footer_long');
140
+ if ((0, exports.whatsappTemplateVariables)(footer).length > 0)
141
+ add('footer_variables');
142
+ const buttons = draft.buttons ?? [];
143
+ if (buttons.length > exports.WHATSAPP_TEMPLATE_BUTTONS_MAX)
144
+ add('buttons_many');
145
+ if (buttons.some(button => !button.trim()))
146
+ add('button_empty');
147
+ if (buttons.some(button => button.trim().length > exports.WHATSAPP_TEMPLATE_BUTTON_MAX)) {
148
+ add('button_long');
149
+ }
150
+ if (buttons.some(button => (0, exports.whatsappTemplateVariables)(button).length > 0)) {
151
+ add('button_variables');
152
+ }
153
+ const unique = new Set(buttons.map(button => button.trim().toLowerCase()));
154
+ if (unique.size < buttons.length)
155
+ add('buttons_repeated');
156
+ const body = draft.body.trim();
157
+ if (!body) {
158
+ add('body_empty');
159
+ return issues;
160
+ }
161
+ if (body.length > exports.WHATSAPP_TEMPLATE_BODY_MAX)
162
+ add('body_long');
163
+ const found = (0, exports.whatsappTemplateVariables)(body);
164
+ if (found.length === 0)
165
+ return issues;
166
+ const count = Math.max(...found);
167
+ if (count > exports.WHATSAPP_TEMPLATE_VARIABLES_MAX)
168
+ add('variables_many');
169
+ // {{1}}, {{2}}, … with no gaps, each first used after the one before.
170
+ const firsts = [...new Set(found)];
171
+ if (firsts.some((n, i) => n !== i + 1))
172
+ add('variables_order');
173
+ if (/^\{\{\s*\d+\s*\}\}/.test(body))
174
+ add('variable_at_start');
175
+ if (/\{\{\s*\d+\s*\}\}[\s.!?…]*$/.test(body))
176
+ add('variable_at_end');
177
+ if (/\}\}\s*\{\{/.test(body))
178
+ add('variables_adjacent');
179
+ // Meta wants more words than variables; this is about the ratio it accepts.
180
+ if (words(body) < 2 * firsts.length + 1)
181
+ add('variables_dense');
182
+ const examples = draft.examples ?? [];
183
+ for (let n = 1; n <= count; n++) {
184
+ if (!examples[n - 1]?.trim())
185
+ add('example_missing', n);
186
+ }
187
+ return issues;
188
+ };
189
+ exports.checkWhatsappTemplate = checkWhatsappTemplate;
190
+ const ISSUE_TEXT = {
191
+ name_format: 'The name takes lowercase letters, digits and underscores only.',
192
+ name_long: `The name is at most ${exports.WHATSAPP_TEMPLATE_NAME_MAX} characters.`,
193
+ language: "The language isn't one Meta supports.",
194
+ category: 'The category is Utility or Marketing.',
195
+ body_empty: 'The message needs a body.',
196
+ body_long: `The body is at most ${exports.WHATSAPP_TEMPLATE_BODY_MAX} characters.`,
197
+ header_long: `The header is at most ${exports.WHATSAPP_TEMPLATE_HEADER_MAX} characters.`,
198
+ header_variables: "The header can't have variables.",
199
+ header_lines: 'The header is one line.',
200
+ footer_long: `The footer is at most ${exports.WHATSAPP_TEMPLATE_FOOTER_MAX} characters.`,
201
+ footer_variables: "The footer can't have variables.",
202
+ button_empty: 'A quick reply needs a text.',
203
+ button_long: `A quick reply is at most ${exports.WHATSAPP_TEMPLATE_BUTTON_MAX} characters.`,
204
+ button_variables: "A quick reply can't have variables.",
205
+ buttons_many: `At most ${exports.WHATSAPP_TEMPLATE_BUTTONS_MAX} quick replies.`,
206
+ buttons_repeated: 'Two quick replies say the same.',
207
+ variables_many: `At most ${exports.WHATSAPP_TEMPLATE_VARIABLES_MAX} variables.`,
208
+ variables_order: 'Variables are numbered {{1}}, {{2}}, … in the order they first appear, with no gaps.',
209
+ variable_at_start: 'Meta rejects a body that starts with a variable.',
210
+ variable_at_end: 'Meta rejects a body that ends with a variable.',
211
+ variables_adjacent: 'Meta rejects two variables side by side; put words between them.',
212
+ variables_dense: 'Too many variables for the length of the text; add words or remove variables.',
213
+ example_missing: 'Each variable needs an example value for Meta to review.'
214
+ };
215
+ const whatsappTemplateIssueText = (issue) => issue.n === undefined
216
+ ? ISSUE_TEXT[issue.code]
217
+ : `{{${issue.n}}}: ${ISSUE_TEXT[issue.code]}`;
218
+ exports.whatsappTemplateIssueText = whatsappTemplateIssueText;
219
+ /** The components Meta's create and edit calls take. */
220
+ const whatsappTemplateComponents = (draft) => {
221
+ const components = [];
222
+ const header = draft.header?.trim();
223
+ if (header)
224
+ components.push({ type: 'HEADER', format: 'TEXT', text: header });
225
+ const body = draft.body.trim();
226
+ const count = (0, exports.whatsappTemplateVariableCount)(body);
227
+ components.push({
228
+ type: 'BODY',
229
+ text: body,
230
+ ...(count > 0
231
+ ? {
232
+ example: {
233
+ body_text: [
234
+ Array.from({ length: count }, (_, i) => (draft.examples?.[i] ?? '').trim())
235
+ ]
236
+ }
237
+ }
238
+ : {})
239
+ });
240
+ const footer = draft.footer?.trim();
241
+ if (footer)
242
+ components.push({ type: 'FOOTER', text: footer });
243
+ const buttons = (draft.buttons ?? []).map(b => b.trim()).filter(Boolean);
244
+ if (buttons.length > 0) {
245
+ components.push({
246
+ type: 'BUTTONS',
247
+ buttons: buttons.map(text => ({ type: 'QUICK_REPLY', text }))
248
+ });
249
+ }
250
+ return components;
251
+ };
252
+ exports.whatsappTemplateComponents = whatsappTemplateComponents;
253
+ /** A template's text, from the components Meta returns. */
254
+ const whatsappTemplateContent = (components) => {
255
+ const content = {
256
+ header: null,
257
+ body: '',
258
+ footer: null,
259
+ buttons: [],
260
+ examples: []
261
+ };
262
+ if (!Array.isArray(components))
263
+ return content;
264
+ for (const component of components) {
265
+ const type = String(component?.type ?? '').toUpperCase();
266
+ const text = typeof component?.text === 'string' ? component.text : null;
267
+ if (type === 'HEADER' && component.format === 'TEXT')
268
+ content.header = text;
269
+ if (type === 'BODY') {
270
+ content.body = text ?? '';
271
+ const example = component.example;
272
+ const first = example?.body_text?.[0];
273
+ if (Array.isArray(first))
274
+ content.examples = first.map(v => String(v));
275
+ }
276
+ if (type === 'FOOTER')
277
+ content.footer = text;
278
+ if (type === 'BUTTONS' && Array.isArray(component.buttons)) {
279
+ content.buttons = component.buttons
280
+ .filter(b => b?.type === 'QUICK_REPLY' && typeof b.text === 'string')
281
+ .map(b => b.text);
282
+ }
283
+ }
284
+ return content;
285
+ };
286
+ exports.whatsappTemplateContent = whatsappTemplateContent;
287
+ /**
288
+ * Whether Ganju can send it: it fills the body's variables only. A media
289
+ * header, a header variable, or a button that takes a value (a URL with a
290
+ * variable, a code, a flow) needs more, and Meta refuses the send.
291
+ */
292
+ const whatsappTemplateSendable = (components) => {
293
+ if (!Array.isArray(components))
294
+ return false;
295
+ return components.every(component => {
296
+ const type = String(component?.type ?? '').toUpperCase();
297
+ if (type === 'BODY' || type === 'FOOTER')
298
+ return true;
299
+ if (type === 'HEADER') {
300
+ return (component.format === 'TEXT' &&
301
+ (0, exports.whatsappTemplateVariables)(String(component.text ?? '')).length === 0);
302
+ }
303
+ if (type === 'BUTTONS') {
304
+ const buttons = Array.isArray(component.buttons)
305
+ ? component.buttons
306
+ : [];
307
+ return buttons.every(button => {
308
+ const kind = String(button?.type ?? '').toUpperCase();
309
+ if (kind === 'QUICK_REPLY' || kind === 'PHONE_NUMBER')
310
+ return true;
311
+ if (kind === 'URL') {
312
+ return ((0, exports.whatsappTemplateVariables)(String(button.url ?? '')).length === 0);
313
+ }
314
+ return false;
315
+ });
316
+ }
317
+ return false;
318
+ });
319
+ };
320
+ exports.whatsappTemplateSendable = whatsappTemplateSendable;
321
+ /** The body with each {{n}} replaced, for previews and the message record. */
322
+ const fillWhatsappTemplate = (body, values) => body.replace(VARIABLE, (whole, n) => values[Number(n) - 1] ?? whole);
323
+ exports.fillWhatsappTemplate = fillWhatsappTemplate;
324
+ /**
325
+ * A value for a {{n}} as Meta accepts it: no line breaks or tabs, no long
326
+ * runs of spaces, and never empty.
327
+ */
328
+ const cleanWhatsappTemplateParam = (value) => {
329
+ const text = String(value ?? '')
330
+ .replace(/[\r\n\t]+/g, ' ')
331
+ .replace(/ {2,}/g, ' ')
332
+ .trim()
333
+ .slice(0, 1024);
334
+ return text || '-';
335
+ };
336
+ exports.cleanWhatsappTemplateParam = cleanWhatsappTemplateParam;
337
+ // Meta's error codes for a message that didn't go out, in words an owner can
338
+ // act on. `retry`: the same message may go through later.
339
+ const SEND_ERRORS = {
340
+ 1: { text: 'WhatsApp had a temporary problem.', retry: true },
341
+ 2: { text: 'WhatsApp had a temporary problem.', retry: true },
342
+ 10: { text: "The channel's access token isn't allowed to send messages." },
343
+ 190: {
344
+ text: "The channel's access token expired or was revoked; connect a new one in the channel's settings."
345
+ },
346
+ 200: { text: "The channel's access token isn't allowed to send messages." },
347
+ 368: {
348
+ text: 'Meta has temporarily blocked this WhatsApp Business account for policy violations.'
349
+ },
350
+ 130429: {
351
+ text: "The number's sending rate was reached; it will be tried again.",
352
+ retry: true
353
+ },
354
+ 131000: { text: 'WhatsApp had a temporary problem.', retry: true },
355
+ 131016: { text: 'WhatsApp is temporarily unavailable.', retry: true },
356
+ 131026: {
357
+ text: "WhatsApp couldn't deliver it: the customer may have blocked the business, left WhatsApp, or not accepted its latest terms."
358
+ },
359
+ 131031: { text: 'Meta locked this WhatsApp Business account.' },
360
+ 131042: {
361
+ text: "Meta refused it over a payment problem on the WhatsApp Business account; check its payment method in Meta's Business settings."
362
+ },
363
+ 131047: {
364
+ text: "Outside WhatsApp's 24-hour window: the customer last wrote more than 24 hours ago, so only an approved template reaches them."
365
+ },
366
+ 131048: {
367
+ text: "Meta limited this number's sending because of its quality: too many customers blocked or reported its messages."
368
+ },
369
+ 131049: {
370
+ text: 'Meta chose not to deliver this marketing message, to limit how many each customer gets.'
371
+ },
372
+ 131050: {
373
+ text: 'The customer turned off marketing messages from this business.'
374
+ },
375
+ 131056: {
376
+ text: 'Too many messages to this customer in a short time; it will be tried again.',
377
+ retry: true
378
+ },
379
+ 132000: {
380
+ text: "The number of values doesn't match the template's variables."
381
+ },
382
+ 132001: {
383
+ text: 'Meta has no approved template with that name in that language.'
384
+ },
385
+ 132005: { text: "The template's text is too long with the values put in." },
386
+ 132007: { text: "The template's text breaks WhatsApp's commerce policy." },
387
+ 132012: { text: "A value doesn't match what the template expects." },
388
+ 132015: { text: 'Meta paused this template because of its quality.' },
389
+ 132016: { text: 'Meta disabled this template because of its quality.' },
390
+ 132018: { text: 'A value has line breaks, tabs or too many spaces.' }
391
+ };
392
+ /** A Meta send error as an owner reads it, and whether a retry may help. */
393
+ const whatsappSendError = (code, detail) => {
394
+ const known = code !== undefined ? SEND_ERRORS[code] : undefined;
395
+ if (known)
396
+ return { text: known.text, retry: !!known.retry };
397
+ return {
398
+ text: detail ? detail.slice(0, 300) : 'WhatsApp refused the message.',
399
+ retry: true
400
+ };
401
+ };
402
+ exports.whatsappSendError = whatsappSendError;
@@ -0,0 +1,28 @@
1
+ export interface WhatsappWebhookChange<V> {
2
+ field?: string;
3
+ value?: V;
4
+ }
5
+ export interface WhatsappWebhookEnvelope<V> {
6
+ entry?: Array<{
7
+ id?: string;
8
+ changes?: Array<WhatsappWebhookChange<V>>;
9
+ }>;
10
+ }
11
+ export interface WhatsappAddressedValue {
12
+ metadata?: {
13
+ phone_number_id?: string;
14
+ };
15
+ messages?: unknown[];
16
+ }
17
+ /**
18
+ * The change carrying a message for this channel's number, wherever Meta put
19
+ * it in the batch. A change that doesn't say which number it's for is kept.
20
+ */
21
+ export declare const whatsappMessageChange: <V extends WhatsappAddressedValue>(payload: WhatsappWebhookEnvelope<V>, phoneNumberId: string) => V | undefined;
22
+ /**
23
+ * The changes with one of `fields` from this channel's business account. An
24
+ * entry without an id, or a channel that hasn't saved its account, keeps
25
+ * them: template events only touch the channel's own stored templates.
26
+ */
27
+ export declare const whatsappAccountChanges: <V>(payload: WhatsappWebhookEnvelope<V>, businessAccountId: string | undefined, fields: readonly string[]) => WhatsappWebhookChange<V>[];
28
+ //# sourceMappingURL=whatsappWebhook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whatsappWebhook.d.ts","sourceRoot":"","sources":["../src/whatsappWebhook.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,CAAC,CAAC;CACX;AAED,MAAM,WAAW,uBAAuB,CAAC,CAAC;IACxC,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAA;KAAE,CAAC,CAAC;CAC3E;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,SAAS,sBAAsB,EACpE,SAAS,uBAAuB,CAAC,CAAC,CAAC,EACnC,eAAe,MAAM,KACpB,CAAC,GAAG,SAQM,CAAC;AAEd;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,GAAI,CAAC,EACtC,SAAS,uBAAuB,CAAC,CAAC,CAAC,EACnC,mBAAmB,MAAM,GAAG,SAAS,EACrC,QAAQ,SAAS,MAAM,EAAE,KACxB,qBAAqB,CAAC,CAAC,CAAC,EAStB,CAAC"}
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ // What one WhatsApp channel takes from a webhook. A Meta app sends every
3
+ // number's events to each of its callbacks, signed with the same app secret,
4
+ // so a channel keeps only what was addressed to its own number and business
5
+ // account; otherwise another channel's bot answers its messages.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.whatsappAccountChanges = exports.whatsappMessageChange = void 0;
8
+ /**
9
+ * The change carrying a message for this channel's number, wherever Meta put
10
+ * it in the batch. A change that doesn't say which number it's for is kept.
11
+ */
12
+ const whatsappMessageChange = (payload, phoneNumberId) => (payload.entry ?? [])
13
+ .flatMap(entry => entry.changes ?? [])
14
+ .find(change => {
15
+ const value = change.value;
16
+ if (!value?.messages?.length)
17
+ return false;
18
+ const to = value.metadata?.phone_number_id;
19
+ return !to || to === phoneNumberId;
20
+ })?.value;
21
+ exports.whatsappMessageChange = whatsappMessageChange;
22
+ /**
23
+ * The changes with one of `fields` from this channel's business account. An
24
+ * entry without an id, or a channel that hasn't saved its account, keeps
25
+ * them: template events only touch the channel's own stored templates.
26
+ */
27
+ const whatsappAccountChanges = (payload, businessAccountId, fields) => (payload.entry ?? [])
28
+ .filter(entry => !entry.id || !businessAccountId || entry.id === businessAccountId)
29
+ .flatMap(entry => (entry.changes ?? []).filter(change => !!change.field && fields.includes(change.field)));
30
+ exports.whatsappAccountChanges = whatsappAccountChanges;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganju/utils",
3
- "version": "0.0.18",
3
+ "version": "0.0.21",
4
4
  "description": "Shared constants, schemas and helpers for Ganju",
5
5
  "license": "Apache-2.0",
6
6
  "author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
package/src/constants.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  CUSTOM_CODE_BROKER_PATH_DATA,
26
26
  CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND,
27
27
  CUSTOM_CODE_BROKER_PATH_CHANNELS_POST,
28
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES,
28
29
  CUSTOM_CODE_HEALTH_TOOL,
29
30
  CUSTOM_CODE_MAX_LOGS,
30
31
  CUSTOM_CODE_MAX_LOG_LENGTH
@@ -2196,6 +2197,36 @@ const DATA_PURGE_BATCH = 20;
2196
2197
  // Postgres write per query.
2197
2198
  const DATA_ACTIVITY_THROTTLE_MS = 60 * 60 * 1000;
2198
2199
 
2200
+ // Index advice. A column is worth an index when, over a week, at least 50
2201
+ // queries filtered or sorted on it and they read on average 1,000 rows and
2202
+ // 20 rows for every row returned. An index no query used in 30 days on a
2203
+ // table written at least 1,000 rows (and more rows written than queries) is
2204
+ // flagged as unused. Never dropped automatically.
2205
+ const DATA_INDEX_ADVICE_WINDOW_DAYS = 7;
2206
+ const DATA_INDEX_ADVICE_MIN_QUERIES = 50;
2207
+ const DATA_INDEX_ADVICE_MIN_ROWS_READ = 1_000;
2208
+ const DATA_INDEX_ADVICE_MIN_READ_RATIO = 20;
2209
+ const DATA_INDEX_UNUSED_WINDOW_DAYS = 30;
2210
+ const DATA_INDEX_UNUSED_MIN_ROWS_WRITTEN = 1_000;
2211
+ // Daily stats are kept past the longest window, then removed.
2212
+ const DATA_QUERY_STAT_KEEP_DAYS = 35;
2213
+ const DATA_INDEX_ADVICE_ADD = 'add' as 'add';
2214
+ const DATA_INDEX_ADVICE_UNUSED = 'unused' as 'unused';
2215
+ const DATA_INDEX_ADVICE_OPEN = 'open' as 'open';
2216
+ const DATA_INDEX_ADVICE_DISMISSED = 'dismissed' as 'dismissed';
2217
+
2218
+ // The daily jobs (index advice and the abandoned-database flow) run on the
2219
+ // hourly cron at this UTC hour.
2220
+ const DATA_DAILY_JOB_HOUR_UTC = 13;
2221
+
2222
+ // Days on Free before a database is warned about, reminded about, and listed
2223
+ // for Ganju to delete by hand.
2224
+ const DATA_ABANDON_WARN_DAYS = 30;
2225
+ const DATA_ABANDON_REMIND_DAYS = 41;
2226
+ const DATA_ABANDON_DELETE_DAYS = 44;
2227
+ // How long the export taken before a database is deleted is kept.
2228
+ const DATA_ABANDON_EXPORT_KEEP_DAYS = 90;
2229
+
2199
2230
  interface PlanLimits {
2200
2231
  // null on any field means "no hard limit" for that plan.
2201
2232
  maxProjects: number | null;
@@ -2271,6 +2302,9 @@ interface PlanLimits {
2271
2302
  maxDataTablesPerDatabase: number | null;
2272
2303
  // Abuse backstop on data trigger runs a month. `null` = no backstop.
2273
2304
  triggerRunHardCap: number | null;
2305
+ // Email trigger runs a day per organization, each to at most 10 addresses.
2306
+ // They go out from Ganju's own domain. `null` = no cap.
2307
+ dailyTriggerEmailCap: number | null;
2274
2308
  }
2275
2309
 
2276
2310
  const PLAN_LIMITS: Record<
@@ -2315,7 +2349,8 @@ const PLAN_LIMITS: Record<
2315
2349
  canUseData: false,
2316
2350
  maxDataBytes: null,
2317
2351
  maxDataTablesPerDatabase: null,
2318
- triggerRunHardCap: 0
2352
+ triggerRunHardCap: 0,
2353
+ dailyTriggerEmailCap: 0
2319
2354
  },
2320
2355
  PRO: {
2321
2356
  maxProjects: null,
@@ -2346,7 +2381,8 @@ const PLAN_LIMITS: Record<
2346
2381
  canUseData: true,
2347
2382
  maxDataBytes: DATA_MAX_DATABASE_BYTES,
2348
2383
  maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2349
- triggerRunHardCap: DATA_TRIGGER_RUN_HARD_CAP
2384
+ triggerRunHardCap: DATA_TRIGGER_RUN_HARD_CAP,
2385
+ dailyTriggerEmailCap: 500
2350
2386
  },
2351
2387
  ENTERPRISE: {
2352
2388
  maxProjects: null,
@@ -2377,7 +2413,8 @@ const PLAN_LIMITS: Record<
2377
2413
  canUseData: true,
2378
2414
  maxDataBytes: DATA_MAX_DATABASE_BYTES,
2379
2415
  maxDataTablesPerDatabase: DATA_MAX_TABLES_PER_DATABASE,
2380
- triggerRunHardCap: null
2416
+ triggerRunHardCap: null,
2417
+ dailyTriggerEmailCap: 5000
2381
2418
  }
2382
2419
  };
2383
2420
 
@@ -2581,6 +2618,22 @@ export const constants = {
2581
2618
  DATA_DELETE_GRACE_DAYS,
2582
2619
  DATA_PURGE_BATCH,
2583
2620
  DATA_ACTIVITY_THROTTLE_MS,
2621
+ DATA_INDEX_ADVICE_WINDOW_DAYS,
2622
+ DATA_INDEX_ADVICE_MIN_QUERIES,
2623
+ DATA_INDEX_ADVICE_MIN_ROWS_READ,
2624
+ DATA_INDEX_ADVICE_MIN_READ_RATIO,
2625
+ DATA_INDEX_UNUSED_WINDOW_DAYS,
2626
+ DATA_INDEX_UNUSED_MIN_ROWS_WRITTEN,
2627
+ DATA_QUERY_STAT_KEEP_DAYS,
2628
+ DATA_INDEX_ADVICE_ADD,
2629
+ DATA_INDEX_ADVICE_UNUSED,
2630
+ DATA_INDEX_ADVICE_OPEN,
2631
+ DATA_INDEX_ADVICE_DISMISSED,
2632
+ DATA_DAILY_JOB_HOUR_UTC,
2633
+ DATA_ABANDON_WARN_DAYS,
2634
+ DATA_ABANDON_REMIND_DAYS,
2635
+ DATA_ABANDON_DELETE_DAYS,
2636
+ DATA_ABANDON_EXPORT_KEEP_DAYS,
2584
2637
  ARTIFACT_DATABASE_STATUS_CREATING,
2585
2638
  ARTIFACT_DATABASE_STATUS_READY,
2586
2639
  ARTIFACT_DATABASE_STATUS_FAILED,
@@ -3113,6 +3166,7 @@ export const constants = {
3113
3166
  CUSTOM_CODE_BROKER_PATH_DATA,
3114
3167
  CUSTOM_CODE_BROKER_PATH_CHANNELS_SEND,
3115
3168
  CUSTOM_CODE_BROKER_PATH_CHANNELS_POST,
3169
+ CUSTOM_CODE_BROKER_PATH_CHANNELS_TEMPLATES,
3116
3170
  CUSTOM_CODE_OUTBOUND_PARAM_ARTIFACT_ID,
3117
3171
  CUSTOM_CODE_OUTBOUND_PARAM_ALLOWED_HOSTS,
3118
3172
  CUSTOM_CODE_PLATFORM_HOSTS,
package/src/crypto.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { Context } from 'hono';
2
1
  import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
3
2
  import { getEnv } from './getEnv';
3
+ import type { EnvSource } from './getEnv';
4
4
  import { base64ToBytes, bytesToBase64 } from './base64';
5
5
 
6
6
  const PREFIX = 'enc:v1:';
@@ -8,7 +8,8 @@ const NONCE_BYTES = 24;
8
8
  const KEY_BYTES = 32;
9
9
  const KEY_ENV = 'CRYPTO_SECRET';
10
10
 
11
- export const getCredentialEncryptionKey = (c: Context) => {
11
+ // A request's context, or anything with the worker's env (a cron, a queue).
12
+ export const getCredentialEncryptionKey = (c: EnvSource) => {
12
13
  const rawKey = getEnv(c, KEY_ENV);
13
14
  if (!rawKey) {
14
15
  throw new Error(`Missing env: ${KEY_ENV}`);
package/src/data.ts CHANGED
@@ -26,6 +26,8 @@ export {
26
26
  encodeCursor,
27
27
  decodeCursor,
28
28
  isSensitiveWrite,
29
+ fileUriOf,
30
+ unknownFileUris,
29
31
  tidyArgs,
30
32
  zoned
31
33
  } from './dataCompiler';
@@ -47,6 +49,8 @@ export {
47
49
  rowHistory,
48
50
  pruneHistory,
49
51
  deleteImpact,
52
+ fileLinks,
53
+ replaceFileLinks,
50
54
  createTable,
51
55
  updateTable,
52
56
  dropTable,
@@ -64,6 +68,12 @@ export {
64
68
  exportRows
65
69
  } from './dataEngine';
66
70
  export type { SqlRow, SqlRunner } from './dataEngine';
71
+ export { adviseIndexes, adviceDoubled } from './dataIndexAdvice';
72
+ export type {
73
+ IndexAdviceCandidate,
74
+ IndexAdviceInput,
75
+ IndexAdviceStat
76
+ } from './dataIndexAdvice';
67
77
  export {
68
78
  DATA_QUERY_PARAM_TYPES,
69
79
  DATA_MAX_QUERIES,
@@ -118,7 +128,10 @@ export {
118
128
  utf8Bytes,
119
129
  conditionMatches,
120
130
  renderTriggerText,
131
+ renderCustomerMessage,
132
+ readEventRow,
121
133
  normaliseTrigger,
134
+ normaliseCustomerAction,
122
135
  listTriggers,
123
136
  getTrigger,
124
137
  saveTrigger,
@@ -138,6 +151,7 @@ export type {
138
151
  DataTriggerAction,
139
152
  DataTriggerActionType,
140
153
  DataTriggerCondition,
154
+ DataTriggerCustomerMessage,
141
155
  DataTriggerEvent,
142
156
  DataTriggerEventMessage,
143
157
  DataTriggerInput,