@fayz-ai/plugin-notifications 0.2.1 → 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 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