@fayz-ai/plugin-agenda 0.16.1 → 0.17.0

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
@@ -10585,6 +10585,222 @@ END $$;
10585
10585
  -- pool must be REVOKED AT GOOGLE, not assumed gone \u2014 which is also why every
10586
10586
  -- tenant re-consents after this file runs.
10587
10587
  `;
10588
+ var MIGRATION_008_THE_OUTBOUND_HALF = `-- ---------------------------------------------------------------------------
10589
+ -- Google Calendar \u2014 the outbound half that 007 does not install
10590
+ -- ---------------------------------------------------------------------------
10591
+ -- 007 is the credential-free install: it creates the five plg_calendar_* tables
10592
+ -- from zero, with no column a Google grant could ever sit in. What it does NOT
10593
+ -- create is the trigger on public.appointments that pushes a booking OUT to
10594
+ -- Google, because that trigger never lived in those tables.
10595
+ --
10596
+ -- Only 001 and 002 create \`trg_gcal_push\`, and 001 is the file whose credential
10597
+ -- columns 007 exists to prevent. So there is no ordering of the existing files
10598
+ -- that gives a fresh pool the outbound direction without also giving it, for a
10599
+ -- while, a plaintext refresh-token column.
10600
+ --
10601
+ -- The result on a pool installed by 007 alone: Google \u2192 Fayz works, Fayz \u2192
10602
+ -- Google is silent. Not an error, not a warning \u2014 appointments simply never
10603
+ -- reach the calendar. Discovered on cluster-squash-test after 007 came up green
10604
+ -- and the tables were correct.
10605
+ --
10606
+ -- This file is the missing half, and nothing else:
10607
+ --
10608
+ -- \xA71 public.fn_gcal_push_booking \u2014 verbatim the definition 005 settled on:
10609
+ -- vault first (\`gcal_sync_url\` + \`gcal_outbound_secret\`), the legacy GUC
10610
+ -- only as a fallback that WARNs, and a no-op when neither is configured.
10611
+ -- Reproduced rather than referenced because 005 cannot run here: it
10612
+ -- replaces a function 001 created, and 001 is the file we are avoiding.
10613
+ -- \xA72 trg_gcal_push on public.appointments \u2014 the shape 002 re-asserts.
10614
+ --
10615
+ -- A pool that already ran 001\u2192006 gets an identical function and an identical
10616
+ -- trigger, which is why this is safe to apply anywhere: every statement is
10617
+ -- CREATE OR REPLACE / DROP-then-CREATE.
10618
+ --
10619
+ -- WITHOUT THE VAULT SECRETS THIS IS INERT ON PURPOSE. The function reads
10620
+ -- \`gcal_sync_url\` and \`gcal_outbound_secret\` from the vault and returns without
10621
+ -- pushing when they are absent, so installing the trigger before the secrets
10622
+ -- exist cannot break an INSERT into appointments \u2014 the most load-bearing table
10623
+ -- the agenda has.
10624
+ --
10625
+ -- Idempotent and replay-safe.
10626
+ -- ---------------------------------------------------------------------------
10627
+
10628
+ -- ===========================================================================
10629
+ -- \xA71 \u2014 the push, as 005 defined it
10630
+ -- ===========================================================================
10631
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
10632
+ LANGUAGE plpgsql
10633
+ SECURITY DEFINER
10634
+ SET search_path = public, extensions, net, vault
10635
+ AS $$
10636
+ DECLARE
10637
+ url text;
10638
+ key text;
10639
+ legacy boolean := false;
10640
+ rec record := COALESCE(NEW, OLD);
10641
+ op text := lower(TG_OP);
10642
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
10643
+ BEGIN
10644
+ -- Unchanged from 004: inbound Google writes mark themselves, and a write
10645
+ -- carrying that marker is never echoed back.
10646
+ IF current_setting('app.booking_origin', true) = 'google' THEN
10647
+ RETURN COALESCE(NEW, OLD);
10648
+ END IF;
10649
+
10650
+ -- Vault first. Wrapped because a pool without the vault extension must
10651
+ -- degrade to the fallback, not abort the booking write.
10652
+ BEGIN
10653
+ SELECT decrypted_secret INTO url
10654
+ FROM vault.decrypted_secrets WHERE name = 'gcal_sync_url'
10655
+ ORDER BY created_at DESC LIMIT 1;
10656
+ SELECT decrypted_secret INTO key
10657
+ FROM vault.decrypted_secrets WHERE name = 'gcal_outbound_secret'
10658
+ ORDER BY created_at DESC LIMIT 1;
10659
+ EXCEPTION WHEN OTHERS THEN
10660
+ url := NULL;
10661
+ key := NULL;
10662
+ END;
10663
+
10664
+ -- Legacy pools provisioned per 001's header keep working, and say so.
10665
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
10666
+ url := COALESCE(NULLIF(url, ''), current_setting('app.gcal_sync_url', true));
10667
+ key := COALESCE(NULLIF(key, ''), current_setting('app.gcal_service_key', true));
10668
+ legacy := NULLIF(key, '') IS NOT NULL;
10669
+ END IF;
10670
+
10671
+ -- Not configured is not an error: an unconfigured pool degrades to "Google is
10672
+ -- not updated", never to "the appointment could not be saved".
10673
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
10674
+ RETURN COALESCE(NEW, OLD);
10675
+ END IF;
10676
+
10677
+ IF legacy THEN
10678
+ RAISE WARNING 'gcal outbound is still using app.gcal_service_key; move it to the vault secret gcal_outbound_secret and rotate the service-role key';
10679
+ END IF;
10680
+
10681
+ -- Does THIS tenant have a calendar at all?
10682
+ --
10683
+ -- Added here, not inherited from 005. The vault secrets are per POOL and the
10684
+ -- integration is per TENANT, so without this every appointment write in the
10685
+ -- pool posted to the sync function \u2014 including for tenants that never
10686
+ -- connected Google. On the cluster this was installed against that is 354.989
10687
+ -- appointments across several tenants, all of them paying for an HTTP round
10688
+ -- trip whose only possible answer is "no integration".
10689
+ --
10690
+ -- The function would have answered correctly; the cost is the asking. Indexed
10691
+ -- by tenant (idx_plg_calendar_integrations_tenant), so the guard is a lookup,
10692
+ -- not a scan.
10693
+ --
10694
+ -- A tenant who DISCONNECTS stops pushing from that moment, which means a
10695
+ -- booking deleted afterwards leaves its Google event behind. That is the right
10696
+ -- trade: the alternative is pushing on behalf of someone who revoked us.
10697
+ IF NOT EXISTS (
10698
+ SELECT 1 FROM public.plg_calendar_integrations i
10699
+ WHERE i.tenant_id = rec.tenant_id AND i.active
10700
+ ) THEN
10701
+ RETURN COALESCE(NEW, OLD);
10702
+ END IF;
10703
+
10704
+ BEGIN
10705
+ PERFORM net.http_post(
10706
+ url := url,
10707
+ headers := jsonb_build_object(
10708
+ 'Content-Type', 'application/json',
10709
+ -- REQUIRED. The deployed function authorises push_event on this header
10710
+ -- alone; without it every push 400s, silently, forever.
10711
+ 'Authorization', 'Bearer ' || key
10712
+ ),
10713
+ body := jsonb_build_object(
10714
+ 'action', 'push_event',
10715
+ 'op', op,
10716
+ 'bookingId', rec.id,
10717
+ 'tenantId', rec.tenant_id,
10718
+ -- Carried so a delete can still be honoured after the row is gone.
10719
+ 'googleEventId', rec.metadata->>'googleCalendarEventId',
10720
+ 'calendarId', rec.metadata->>'gcalCalendarId',
10721
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
10722
+ )
10723
+ );
10724
+ EXCEPTION WHEN OTHERS THEN
10725
+ -- Missing pg_net, a bad URL, a full queue: the appointment is still saved.
10726
+ -- The deployed trigger lacks this wrapper; 004 added it here after a pool
10727
+ -- with the settings present but the extension missing took an INSERT down.
10728
+ RAISE WARNING 'gcal push skipped for booking %: %', rec.id, SQLERRM;
10729
+ END;
10730
+
10731
+ RETURN COALESCE(NEW, OLD);
10732
+ END $$;
10733
+
10734
+ -- \xA72 \u2500\u2500 cleanup \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
10735
+ -- Redefining the function stops the key being PREFERRED; it does not stop it
10736
+ -- being STORED. Settings live in pg_db_role_setting per (database, role), so the
10737
+ -- sweep is driven off the catalog rather than off the one ALTER DATABASE form
10738
+ -- \`001\` happened to show.
10739
+ --
10740
+ -- CONDITIONAL ON PURPOSE: the setting is cleared only where Vault already
10741
+ -- supplies \`gcal_outbound_secret\`, i.e. only where \xA71 provably never needs the
10742
+ -- fallback. Anywhere else it is left in place and reported, because clearing it
10743
+ -- there would disarm the push with no error anyone can see. On the live pool
10744
+ -- this loop finds nothing \u2014 the setting is not present on any role or database \u2014
10745
+ -- and that is the expected result, not a failure.
10746
+ DO $$
10747
+ DECLARE
10748
+ r record;
10749
+ has_vault boolean := false;
10750
+ BEGIN
10751
+ BEGIN
10752
+ SELECT EXISTS (
10753
+ SELECT 1 FROM vault.decrypted_secrets
10754
+ WHERE name = 'gcal_outbound_secret' AND NULLIF(decrypted_secret, '') IS NOT NULL
10755
+ ) INTO has_vault;
10756
+ EXCEPTION WHEN OTHERS THEN
10757
+ has_vault := false;
10758
+ END;
10759
+
10760
+ FOR r IN
10761
+ SELECT d.datname, a.rolname
10762
+ FROM pg_db_role_setting s
10763
+ LEFT JOIN pg_database d ON d.oid = s.setdatabase
10764
+ LEFT JOIN pg_roles a ON a.oid = s.setrole
10765
+ WHERE EXISTS (
10766
+ SELECT 1 FROM unnest(s.setconfig) AS c WHERE c LIKE 'app.gcal_service_key=%'
10767
+ )
10768
+ LOOP
10769
+ IF NOT has_vault THEN
10770
+ RAISE WARNING 'app.gcal_service_key is still set (database %, role %) and is the ONLY outbound credential \u2014 create the vault secret gcal_outbound_secret first, then re-run this migration',
10771
+ r.datname, r.rolname;
10772
+ CONTINUE;
10773
+ END IF;
10774
+ BEGIN
10775
+ IF r.rolname IS NULL THEN
10776
+ EXECUTE format('ALTER DATABASE %I RESET app.gcal_service_key', r.datname);
10777
+ ELSIF r.datname IS NULL THEN
10778
+ EXECUTE format('ALTER ROLE %I RESET app.gcal_service_key', r.rolname);
10779
+ ELSE
10780
+ EXECUTE format('ALTER ROLE %I IN DATABASE %I RESET app.gcal_service_key', r.rolname, r.datname);
10781
+ END IF;
10782
+ EXCEPTION WHEN OTHERS THEN
10783
+ RAISE WARNING 'could not clear app.gcal_service_key (database %, role %): % \u2014 clear it by hand, and rotate the key either way',
10784
+ r.datname, r.rolname, SQLERRM;
10785
+ END;
10786
+ END LOOP;
10787
+ END $$;
10788
+
10789
+ -- ===========================================================================
10790
+ -- \xA72 \u2014 the trigger 007 left out
10791
+ --
10792
+ -- AFTER INSERT OR UPDATE OR DELETE: a booking that is moved or cancelled has to
10793
+ -- reach Google too, and a trigger that only fired on INSERT would leave the
10794
+ -- calendar showing an appointment the CRM no longer has.
10795
+ -- ===========================================================================
10796
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
10797
+ CREATE TRIGGER trg_gcal_push
10798
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
10799
+ FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
10800
+
10801
+ COMMENT ON FUNCTION public.fn_gcal_push_booking() IS
10802
+ 'Pushes a booking to Google Calendar over pg_net, authorised by the vault secret gcal_outbound_secret. Inert while the vault secrets are absent (008).';
10803
+ `;
10588
10804
 
10589
10805
  // src/integrations/google-calendar/connector.ts
10590
10806
  var googleCalendarConnector = {
@@ -10723,6 +10939,12 @@ function createGoogleCalendarPlugin() {
10723
10939
  version: "0.9.3",
10724
10940
  sql: MIGRATION_007_CREDENTIAL_FREE_INSTALL,
10725
10941
  description: "Install the whole connector schema in its post-FAY-1359 shape, from zero, with no credential column at any point \u2014 the five tables the beauty pool had dropped, plus a gate that drops a token column wherever it is still found and refuses to finish if one survives"
10942
+ },
10943
+ {
10944
+ id: "google-calendar-008_the_outbound_half",
10945
+ version: "0.16.2",
10946
+ sql: MIGRATION_008_THE_OUTBOUND_HALF,
10947
+ description: "Install trg_gcal_push and the 005 push function on a pool that came up through 007. 007 creates the tables but not the trigger, and the only files that create it (001, 002) also create the credential columns 007 exists to prevent \u2014 so a fresh pool silently had inbound only. Inert until the vault secrets exist"
10726
10948
  }
10727
10949
  ]
10728
10950
  };