@fayz-ai/plugin-notifications 0.2.2 → 0.2.3
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.
- package/dist/index.js +261 -1
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1322,9 +1322,269 @@ REVOKE ALL ON FUNCTION public.notifications_history_summary(integer) FROM PUBLIC
|
|
|
1322
1322
|
GRANT EXECUTE ON FUNCTION public.notifications_history_summary(integer) TO authenticated;
|
|
1323
1323
|
GRANT EXECUTE ON FUNCTION public.notifications_history_summary(integer) TO service_role;
|
|
1324
1324
|
`;
|
|
1325
|
+
var MIGRATION_002_THE_OPT_OUT_IS_CHECKED_BY_PHONE = `-- 002_the_opt_out_is_checked_by_phone.sql
|
|
1326
|
+
--
|
|
1327
|
+
-- THE OPT-OUT WAS NEVER CHECKED, AND SAID SO ONLY IN A NOTICE.
|
|
1328
|
+
--
|
|
1329
|
+
-- \`notifications_resolve_channel\` opens with the strongest comment in the
|
|
1330
|
+
-- plugin: the opt-out "is absolute and is checked first \u2026 no template, purpose
|
|
1331
|
+
-- or preference of ours outranks it." It then read a column that does not
|
|
1332
|
+
-- exist. \`plg_conversations_optouts\` is keyed by \`phone_e164\` \u2014 there is no
|
|
1333
|
+
-- \`person_id\` on it and there never was, because the record arrives from a
|
|
1334
|
+
-- WhatsApp webhook that knows a phone number and nothing else.
|
|
1335
|
+
--
|
|
1336
|
+
-- The query therefore raised 42703 on EVERY call. A blanket
|
|
1337
|
+
-- \`EXCEPTION WHEN OTHERS\` caught it, set \`v_optout := false\` and continued. So
|
|
1338
|
+
-- the absolute check returned "not opted out" for everybody, always, and the
|
|
1339
|
+
-- only trace was a NOTICE nobody reads.
|
|
1340
|
+
--
|
|
1341
|
+
-- Two things are wrong there and both are fixed here.
|
|
1342
|
+
--
|
|
1343
|
+
-- 1. THE LOOKUP. The opt-out is about a phone; the queue targets a person. The
|
|
1344
|
+
-- bridge is \`people.phone\`, which is free text \u2014 a direct comparison against
|
|
1345
|
+
-- an E.164 column would match almost nothing and would be the same silent
|
|
1346
|
+
-- failure wearing a different coat. \`notifications_phone_key\` reduces both
|
|
1347
|
+
-- sides to the same national digits, following the convention
|
|
1348
|
+
-- \`plg_conversations_match_by_phone\` already established for this exact
|
|
1349
|
+
-- problem: strip everything that is not a digit, drop a leading country
|
|
1350
|
+
-- code, and repair a legacy 10-digit Brazilian mobile by restoring the 9.
|
|
1351
|
+
-- It compares the WHOLE national number rather than \`right(digits, 10)\`,
|
|
1352
|
+
-- because the last ten digits of an 11-digit national number drop the first
|
|
1353
|
+
-- digit of the area code and collide across DDDs \u2014 11 and 21 produce the
|
|
1354
|
+
-- same key. For matching a thread that is a tolerable bet; for deciding
|
|
1355
|
+
-- whether someone asked to be left alone it is not.
|
|
1356
|
+
--
|
|
1357
|
+
-- 2. THE FAILURE DIRECTION. Consent fails CLOSED now. If the table is there and
|
|
1358
|
+
-- the read fails for a reason this version does not understand, the message
|
|
1359
|
+
-- is blocked with a reason rather than sent. A blocked message is a row on
|
|
1360
|
+
-- the history screen with the reason next to it, and somebody can act on it;
|
|
1361
|
+
-- a message sent to someone who asked to stop cannot be recalled. The
|
|
1362
|
+
-- \`to_regclass\` guard stays as it was: a pool without plugin-conversations
|
|
1363
|
+
-- has no opt-out records, which is an answer, not an error.
|
|
1364
|
+
--
|
|
1365
|
+
-- What is deliberately NOT changed: the check stays ABSOLUTE rather than
|
|
1366
|
+
-- becoming per-channel. \`plg_conversations_optouts.channel\` would allow "he
|
|
1367
|
+
-- stopped WhatsApp, e-mail is still fine", and that may well be what the
|
|
1368
|
+
-- business wants \u2014 but it loosens consent, and loosening consent is a product
|
|
1369
|
+
-- decision, not a bug fix. Over-blocking is the safe direction to be wrong in.
|
|
1370
|
+
--
|
|
1371
|
+
-- Scope of the damage until now: the queue's \`whatsapp\` sender only writes a
|
|
1372
|
+
-- \`queued\` row into \`plg_conversation_messages\` and nothing in the repo drains
|
|
1373
|
+
-- it, and the edge function \`messaging-send\` checks the opt-out correctly by
|
|
1374
|
+
-- phone on its own path. So this was a live hole in the QUEUE, which is exactly
|
|
1375
|
+
-- what any future outbound cadence would be built on.
|
|
1376
|
+
|
|
1377
|
+
SET check_function_bodies = false;
|
|
1378
|
+
|
|
1379
|
+
-- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1380
|
+
|
|
1381
|
+
-- The comparable form of a phone number: national digits, country code gone,
|
|
1382
|
+
-- legacy mobile repaired. NULL when the input cannot be a phone number, so that
|
|
1383
|
+
-- a person with no phone can never accidentally equal an opt-out row.
|
|
1384
|
+
CREATE OR REPLACE FUNCTION public.notifications_phone_key(p_phone text)
|
|
1385
|
+
RETURNS text
|
|
1386
|
+
LANGUAGE plpgsql IMMUTABLE
|
|
1387
|
+
SET search_path TO ''
|
|
1388
|
+
AS $$
|
|
1389
|
+
DECLARE d text; national text;
|
|
1390
|
+
BEGIN
|
|
1391
|
+
d := regexp_replace(coalesce(p_phone, ''), '[^0-9]', '', 'g');
|
|
1392
|
+
IF d = '' THEN RETURN NULL; END IF;
|
|
1393
|
+
|
|
1394
|
+
-- A leading 55 is the country code only when something remains that could be
|
|
1395
|
+
-- a national number; '5511' is a landline in DDD 55, not a country code.
|
|
1396
|
+
national := CASE WHEN length(d) > 11 AND left(d, 2) = '55'
|
|
1397
|
+
THEN right(d, length(d) - 2)
|
|
1398
|
+
ELSE d END;
|
|
1399
|
+
|
|
1400
|
+
-- The TRUNK PREFIX. '(011) 99888-7777' is how half of Brazil still writes a
|
|
1401
|
+
-- number, and keeping the 0 made it a different key from the E.164 the
|
|
1402
|
+
-- opt-out stores \u2014 a silent MISS, which on a consent check means the message
|
|
1403
|
+
-- goes out. Stripped after the country code, because '055...' exists too.
|
|
1404
|
+
IF length(national) IN (11, 12) AND left(national, 1) = '0' THEN
|
|
1405
|
+
national := right(national, length(national) - 1);
|
|
1406
|
+
END IF;
|
|
1407
|
+
|
|
1408
|
+
-- Restore the ninth digit on a pre-2016 mobile so that the same person
|
|
1409
|
+
-- written both ways resolves to one key.
|
|
1410
|
+
--
|
|
1411
|
+
-- KNOWN AMBIGUITY, not solved here: a ten-digit foreign number stored with
|
|
1412
|
+
-- no country code \u2014 '(917) 555-1234' \u2014 is indistinguishable from a Brazilian
|
|
1413
|
+
-- one and gets repaired into DDD 91. In a Brazilian product a bare national
|
|
1414
|
+
-- number IS Brazilian; storing a US number without its +1 is already lossy.
|
|
1415
|
+
-- The failure needs a real opt-out on that exact DDD-91 number to bite.
|
|
1416
|
+
IF length(national) = 10 AND substr(national, 3, 1) ~ '^[6-9]$' THEN
|
|
1417
|
+
national := left(national, 2) || '9' || right(national, 8);
|
|
1418
|
+
END IF;
|
|
1419
|
+
|
|
1420
|
+
-- Shorter than eight digits is not a phone number; returning it would let a
|
|
1421
|
+
-- truncated string match a real opt-out. Longer than eleven is not one
|
|
1422
|
+
-- either \u2014 it is two numbers, or a number with an extension glued on, and
|
|
1423
|
+
-- \`notifications_phone_keys\` is what takes those apart.
|
|
1424
|
+
IF length(national) < 8 OR length(national) > 11 THEN RETURN NULL; END IF;
|
|
1425
|
+
RETURN national;
|
|
1426
|
+
END $$;
|
|
1427
|
+
|
|
1428
|
+
-- Every number a free-text field might be carrying.
|
|
1429
|
+
--
|
|
1430
|
+
-- \`people.phone\` has no format and no normalising trigger. Real rows hold
|
|
1431
|
+
-- '(11) 99888-7777 / (11) 3333-4444' and '(11) 99888-7777 ramal 12', and
|
|
1432
|
+
-- comparing the whole string to an E.164 opt-out misses both \u2014 the person asked
|
|
1433
|
+
-- to stop and the message goes out anyway.
|
|
1434
|
+
--
|
|
1435
|
+
-- So the left-hand side is a SET. The regex takes runs that could be a number
|
|
1436
|
+
-- (digits and the punctuation people put between them) and stops at anything
|
|
1437
|
+
-- else \u2014 a slash, the word "ramal" \u2014 which is what separates one number from
|
|
1438
|
+
-- the next.
|
|
1439
|
+
CREATE OR REPLACE FUNCTION public.notifications_phone_keys(p_phone text)
|
|
1440
|
+
RETURNS text[]
|
|
1441
|
+
LANGUAGE sql IMMUTABLE
|
|
1442
|
+
SET search_path TO ''
|
|
1443
|
+
AS $$
|
|
1444
|
+
SELECT coalesce(
|
|
1445
|
+
array_agg(DISTINCT k) FILTER (WHERE k IS NOT NULL),
|
|
1446
|
+
'{}'::text[]
|
|
1447
|
+
)
|
|
1448
|
+
FROM (
|
|
1449
|
+
SELECT public.notifications_phone_key(m[1]) AS k
|
|
1450
|
+
FROM regexp_matches(coalesce(p_phone, ''), '\\+?[0-9][0-9\\s().-]{6,}', 'g') AS m
|
|
1451
|
+
) s;
|
|
1452
|
+
$$;
|
|
1453
|
+
|
|
1454
|
+
COMMENT ON FUNCTION public.notifications_phone_keys(text) IS
|
|
1455
|
+
'Every comparable phone key a free-text contact field carries. One field can hold two numbers.';
|
|
1456
|
+
|
|
1457
|
+
REVOKE ALL ON FUNCTION public.notifications_phone_keys(text) FROM PUBLIC;
|
|
1458
|
+
GRANT EXECUTE ON FUNCTION public.notifications_phone_keys(text) TO service_role;
|
|
1459
|
+
|
|
1460
|
+
COMMENT ON FUNCTION public.notifications_phone_key(text) IS
|
|
1461
|
+
'National digits of a phone number, for comparing a free-text people.phone against an E.164 opt-out record.';
|
|
1462
|
+
|
|
1463
|
+
REVOKE ALL ON FUNCTION public.notifications_phone_key(text) FROM PUBLIC;
|
|
1464
|
+
GRANT EXECUTE ON FUNCTION public.notifications_phone_key(text) TO service_role;
|
|
1465
|
+
|
|
1466
|
+
CREATE OR REPLACE FUNCTION public.notifications_resolve_channel(p_tenant_id uuid, p_key text, p_person_id uuid, p_locale text DEFAULT 'pt-BR'::text, p_requested text DEFAULT NULL::text, OUT channel text, OUT blocked_reason text) RETURNS record
|
|
1467
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
1468
|
+
SET search_path TO ''
|
|
1469
|
+
AS $_$
|
|
1470
|
+
DECLARE v_pref record; v_default text; v_optout boolean := false; v_phone_keys text[];
|
|
1471
|
+
BEGIN
|
|
1472
|
+
-- plugin-conversations' opt-out is absolute and is checked first: it is the
|
|
1473
|
+
-- record of the person telling the business to stop, and no template, purpose
|
|
1474
|
+
-- or preference of ours outranks it.
|
|
1475
|
+
--
|
|
1476
|
+
-- Matched by PHONE, because that is the only thing the opt-out carries. See
|
|
1477
|
+
-- the header of this migration for why the previous person_id comparison
|
|
1478
|
+
-- could never have matched and why it failed quietly.
|
|
1479
|
+
IF to_regclass('public.plg_conversations_optouts') IS NOT NULL THEN
|
|
1480
|
+
BEGIN
|
|
1481
|
+
SELECT public.notifications_phone_keys(pe.phone) INTO v_phone_keys
|
|
1482
|
+
FROM public.people pe
|
|
1483
|
+
WHERE pe.id = p_person_id;
|
|
1484
|
+
|
|
1485
|
+
-- The query runs even when the person has NO phone, on purpose. Guarding
|
|
1486
|
+
-- it on a non-empty key set meant a table whose shape this version does
|
|
1487
|
+
-- not understand was only ever detected for people who happened to have a
|
|
1488
|
+
-- number \u2014 everybody else sailed past the broken check and was sent to.
|
|
1489
|
+
-- An empty array simply matches nothing.
|
|
1490
|
+
--
|
|
1491
|
+
-- \`tenant_id IS NULL\` is load-bearing: \`tyxter-webhook\` writes every
|
|
1492
|
+
-- opt-out with a null tenant, so a tenant-only comparison would match
|
|
1493
|
+
-- none of the records that actually exist.
|
|
1494
|
+
EXECUTE 'SELECT EXISTS (
|
|
1495
|
+
SELECT 1 FROM public.plg_conversations_optouts o
|
|
1496
|
+
WHERE (o.tenant_id = $1 OR o.tenant_id IS NULL)
|
|
1497
|
+
AND public.notifications_phone_key(o.phone_e164) = ANY ($2))'
|
|
1498
|
+
INTO v_optout USING p_tenant_id, coalesce(v_phone_keys, '{}'::text[]);
|
|
1499
|
+
EXCEPTION WHEN OTHERS THEN
|
|
1500
|
+
-- RAISE, do not block.
|
|
1501
|
+
--
|
|
1502
|
+
-- Fail-closed was right; writing a blocked ROW to achieve it was not.
|
|
1503
|
+
-- \`notifications_enqueue\` stores the caller's \`dedupe_key\` on that row and
|
|
1504
|
+
-- short-circuits on it forever after, so one transient read failure
|
|
1505
|
+
-- destroyed that message permanently \u2014 including an invoice-due or an
|
|
1506
|
+
-- order confirmation, whose dedupe keys are per-event and never come
|
|
1507
|
+
-- round again.
|
|
1508
|
+
--
|
|
1509
|
+
-- Raising instead leaves the delivery to \`consume_event_log\`, which
|
|
1510
|
+
-- already retries with backoff and abandons visibly after max_attempts.
|
|
1511
|
+
-- Nothing is sent either way; only one of the two is recoverable.
|
|
1512
|
+
RAISE EXCEPTION 'notifications_resolve_channel: could not read plg_conversations_optouts (%)', SQLERRM
|
|
1513
|
+
USING ERRCODE = '55000';
|
|
1514
|
+
END;
|
|
1515
|
+
END IF;
|
|
1516
|
+
|
|
1517
|
+
IF v_optout THEN
|
|
1518
|
+
channel := NULL; blocked_reason := 'conversations opt-out'; RETURN;
|
|
1519
|
+
END IF;
|
|
1520
|
+
|
|
1521
|
+
IF p_requested IS NOT NULL THEN
|
|
1522
|
+
IF EXISTS (SELECT 1 FROM public.plg_notifications_preferences p
|
|
1523
|
+
WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id
|
|
1524
|
+
AND p.channel = p_requested AND NOT p.opted_in) THEN
|
|
1525
|
+
channel := NULL; blocked_reason := format('recipient opted out of %s', p_requested); RETURN;
|
|
1526
|
+
END IF;
|
|
1527
|
+
channel := p_requested; blocked_reason := NULL; RETURN;
|
|
1528
|
+
END IF;
|
|
1529
|
+
|
|
1530
|
+
SELECT p.channel INTO v_pref
|
|
1531
|
+
FROM public.plg_notifications_preferences p
|
|
1532
|
+
JOIN public.plg_notifications_templates t
|
|
1533
|
+
ON t.tenant_id = p.tenant_id AND t.key = p_key AND t.channel = p.channel
|
|
1534
|
+
AND t.locale = p_locale AND t.is_active
|
|
1535
|
+
WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id AND p.opted_in
|
|
1536
|
+
ORDER BY p.priority, p.channel
|
|
1537
|
+
LIMIT 1;
|
|
1538
|
+
|
|
1539
|
+
IF v_pref.channel IS NOT NULL THEN
|
|
1540
|
+
channel := v_pref.channel; blocked_reason := NULL; RETURN;
|
|
1541
|
+
END IF;
|
|
1542
|
+
|
|
1543
|
+
-- No preference is not "no message": the template declares the fallback.
|
|
1544
|
+
SELECT t.channel INTO v_default
|
|
1545
|
+
FROM public.plg_notifications_templates t
|
|
1546
|
+
WHERE t.tenant_id = p_tenant_id AND t.key = p_key AND t.locale = p_locale
|
|
1547
|
+
AND t.is_active AND t.is_default
|
|
1548
|
+
LIMIT 1;
|
|
1549
|
+
|
|
1550
|
+
IF v_default IS NULL THEN
|
|
1551
|
+
SELECT t.channel INTO v_default
|
|
1552
|
+
FROM public.plg_notifications_templates t
|
|
1553
|
+
WHERE t.tenant_id = p_tenant_id AND t.key = p_key AND t.locale = p_locale AND t.is_active
|
|
1554
|
+
ORDER BY t.channel LIMIT 1;
|
|
1555
|
+
END IF;
|
|
1556
|
+
|
|
1557
|
+
IF v_default IS NOT NULL
|
|
1558
|
+
AND EXISTS (SELECT 1 FROM public.plg_notifications_preferences p
|
|
1559
|
+
WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id
|
|
1560
|
+
AND p.channel = v_default AND NOT p.opted_in) THEN
|
|
1561
|
+
channel := NULL; blocked_reason := format('recipient opted out of %s', v_default); RETURN;
|
|
1562
|
+
END IF;
|
|
1563
|
+
|
|
1564
|
+
channel := v_default;
|
|
1565
|
+
blocked_reason := CASE WHEN v_default IS NULL THEN 'no active template for this key' END;
|
|
1566
|
+
END $_$;
|
|
1567
|
+
|
|
1568
|
+
-- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1569
|
+
|
|
1570
|
+
-- The lookup is \`notifications_phone_key(o.phone_e164) = ANY(...)\`, which no
|
|
1571
|
+
-- existing index can serve: without this it is a sequential scan of the whole
|
|
1572
|
+
-- opt-out table on EVERY enqueue, and because \`tyxter-webhook\` writes every
|
|
1573
|
+
-- record with a null tenant, that table is cluster-wide and only grows.
|
|
1574
|
+
--
|
|
1575
|
+
-- Guarded: plugin-conversations owns the table and a pool may not have it.
|
|
1576
|
+
DO $$
|
|
1577
|
+
BEGIN
|
|
1578
|
+
IF to_regclass('public.plg_conversations_optouts') IS NOT NULL THEN
|
|
1579
|
+
EXECUTE 'CREATE INDEX IF NOT EXISTS plg_conversations_optouts_phone_key_idx
|
|
1580
|
+
ON public.plg_conversations_optouts (public.notifications_phone_key(phone_e164))';
|
|
1581
|
+
END IF;
|
|
1582
|
+
END $$;
|
|
1583
|
+
`;
|
|
1325
1584
|
var MIGRATIONS = [
|
|
1326
1585
|
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
1327
|
-
{ id: "001_o_historico_diz_por_que_nao_chegou", sql: MIGRATION_001_O_HISTORICO_DIZ_POR_QUE_NAO_CHEGOU }
|
|
1586
|
+
{ id: "001_o_historico_diz_por_que_nao_chegou", sql: MIGRATION_001_O_HISTORICO_DIZ_POR_QUE_NAO_CHEGOU },
|
|
1587
|
+
{ id: "002_the_opt_out_is_checked_by_phone", sql: MIGRATION_002_THE_OPT_OUT_IS_CHECKED_BY_PHONE }
|
|
1328
1588
|
];
|
|
1329
1589
|
|
|
1330
1590
|
// src/data/tables.ts
|