@fayz-ai/plugin-notifications 0.2.2 → 0.2.4

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.
@@ -1,5 +1,6 @@
1
1
  export declare const MIGRATION_000_BASELINE = "-- ============================================================================\n-- plugins/plugin-notifications/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.\n--\n-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:\n-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs\n-- prove the result still matches.\n--\n-- This replaced 3 migration files. They are kept, unapplied, in\n-- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no\n-- longer worth replaying.\n--\n-- Object order is fixed rather than dependency-sorted: schemas, types,\n-- sequences, tables, defaults, functions, constraints, views, indexes, foreign\n-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units\n-- are carried by the order packages/db/chain.json declares.\n-- ============================================================================\n\n-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions\n-- sorted by name are not sorted by who calls whom. This is what lets them load\n-- in any order; every body is still compiled on first call, so a genuinely\n-- broken reference surfaces then rather than never. Session-scoped.\nSET check_function_bodies = false;\n\n\n\n-- \u2500\u2500 default privileges: reset \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\n-- Cleared BEFORE anything is created, so every object below gets the ACL the\n-- dump describes rather than the image's defaults on top of it. Restored at\n-- the end of this file.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\n\n-- \u2500\u2500 table \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\n\nCREATE TABLE public.plg_notifications_channels (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid,\n channel text NOT NULL,\n sender_function text NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_notifications_channels_channel_check CHECK ((channel = ANY (ARRAY['email'::text, 'sms'::text, 'whatsapp'::text, 'push'::text, 'in_app'::text])))\n);\n\nCREATE TABLE public.plg_notifications_deliveries (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n queue_id uuid NOT NULL,\n channel text NOT NULL,\n provider text,\n provider_ref text,\n delivered_at timestamp with time zone,\n failed_at timestamp with time zone,\n error text,\n response jsonb DEFAULT '{}'::jsonb NOT NULL,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nALTER TABLE ONLY public.plg_notifications_deliveries FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_notifications_preferences (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n person_id uuid NOT NULL,\n channel text NOT NULL,\n opted_in boolean DEFAULT true NOT NULL,\n quiet_hours jsonb,\n priority integer DEFAULT 100 NOT NULL,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_notifications_preferences_channel_check CHECK ((channel = ANY (ARRAY['email'::text, 'sms'::text, 'whatsapp'::text, 'push'::text, 'in_app'::text])))\n);\n\nALTER TABLE ONLY public.plg_notifications_preferences FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_notifications_queue (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n template_key text NOT NULL,\n person_id uuid,\n channel text NOT NULL,\n locale text DEFAULT 'pt-BR'::text NOT NULL,\n purpose text DEFAULT 'transactional'::text NOT NULL,\n payload jsonb DEFAULT '{}'::jsonb NOT NULL,\n subject text,\n body text,\n scheduled_for timestamp with time zone DEFAULT now() NOT NULL,\n status text DEFAULT 'pending'::text NOT NULL,\n attempts integer DEFAULT 0 NOT NULL,\n max_attempts integer DEFAULT 5 NOT NULL,\n next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,\n last_error text,\n blocked_reason text,\n dedupe_key text,\n source_event_id uuid,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_notifications_queue_max_attempts_check CHECK (((max_attempts >= 1) AND (max_attempts <= 50))),\n CONSTRAINT plg_notifications_queue_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'sent'::text, 'failed'::text, 'abandoned'::text, 'blocked'::text, 'cancelled'::text])))\n);\n\nALTER TABLE ONLY public.plg_notifications_queue FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_notifications_templates (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n key text NOT NULL,\n channel text NOT NULL,\n locale text DEFAULT 'pt-BR'::text NOT NULL,\n purpose text DEFAULT 'transactional'::text NOT NULL,\n subject text,\n body text NOT NULL,\n variables jsonb DEFAULT '[]'::jsonb NOT NULL,\n is_default boolean DEFAULT false NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_by uuid,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_notifications_templates_channel_check CHECK ((channel = ANY (ARRAY['email'::text, 'sms'::text, 'whatsapp'::text, 'push'::text, 'in_app'::text]))),\n CONSTRAINT plg_notifications_templates_purpose_check CHECK ((purpose = ANY (ARRAY['transactional'::text, 'marketing'::text])))\n);\n\nALTER TABLE ONLY public.plg_notifications_templates FORCE ROW LEVEL SECURITY;\n\n\n-- \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\n\nCREATE FUNCTION public.notifications_drain(p_limit integer DEFAULT 100) RETURNS TABLE(claimed integer, sent integer, failed integer, abandoned integer)\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $_$\nDECLARE\n q public.plg_notifications_queue%ROWTYPE;\n v_fn text; v_res jsonb;\n v_claimed integer := 0; v_sent integer := 0; v_failed integer := 0; v_abandoned integer := 0;\nBEGIN\n FOR q IN\n SELECT * FROM public.plg_notifications_queue\n WHERE status = 'pending' AND next_attempt_at <= now() AND scheduled_for <= now()\n ORDER BY scheduled_for\n LIMIT greatest(p_limit, 0)\n FOR UPDATE SKIP LOCKED\n LOOP\n v_claimed := v_claimed + 1;\n\n SELECT c.sender_function INTO v_fn\n FROM public.plg_notifications_channels c\n WHERE c.channel = q.channel AND c.is_active\n AND (c.tenant_id = q.tenant_id OR c.tenant_id IS NULL)\n ORDER BY (c.tenant_id IS NOT NULL) DESC\n LIMIT 1;\n\n IF v_fn IS NULL THEN\n UPDATE public.plg_notifications_queue\n SET status = 'blocked', blocked_reason = format('no sender registered for channel %s', q.channel)\n WHERE id = q.id;\n CONTINUE;\n END IF;\n\n BEGIN\n EXECUTE format('SELECT %s($1)', v_fn) INTO v_res USING q.id;\n UPDATE public.plg_notifications_queue\n SET status = 'sent', attempts = q.attempts + 1, last_error = NULL\n WHERE id = q.id;\n v_sent := v_sent + 1;\n EXCEPTION WHEN OTHERS THEN\n -- Fails alone, with backoff, and is abandoned VISIBLY rather than retried\n -- forever. Same discipline as the event consumer of spine 149.\n IF q.attempts + 1 >= q.max_attempts THEN\n UPDATE public.plg_notifications_queue\n SET status = 'abandoned', attempts = q.attempts + 1, last_error = SQLERRM\n WHERE id = q.id;\n v_abandoned := v_abandoned + 1;\n ELSE\n UPDATE public.plg_notifications_queue\n SET attempts = q.attempts + 1, last_error = SQLERRM,\n next_attempt_at = now() + least(interval '1 hour', (power(2, q.attempts + 1) || ' seconds')::interval)\n WHERE id = q.id;\n v_failed := v_failed + 1;\n END IF;\n INSERT INTO public.plg_notifications_deliveries (tenant_id, unit_id, queue_id, channel, provider, failed_at, error)\n VALUES (q.tenant_id, q.unit_id, q.id, q.channel, v_fn, now(), SQLERRM);\n END;\n END LOOP;\n\n RETURN QUERY SELECT v_claimed, v_sent, v_failed, v_abandoned;\nEND $_$;\n\nCREATE FUNCTION public.notifications_enqueue(p_tenant_id uuid, p_template_key text, p_person_id uuid, p_payload jsonb DEFAULT '{}'::jsonb, p_dedupe_key text DEFAULT NULL::text, p_scheduled_for timestamp with time zone DEFAULT now(), p_channel text DEFAULT NULL::text, p_locale text DEFAULT NULL::text, p_unit_id uuid DEFAULT NULL::uuid, p_source_event_id uuid DEFAULT NULL::uuid) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_locale text := coalesce(p_locale, 'pt-BR');\n v_channel text;\n v_blocked text;\n v_tpl public.plg_notifications_templates%ROWTYPE;\n v_id uuid;\n v_existing uuid;\nBEGIN\n IF p_tenant_id IS NULL OR p_template_key IS NULL THEN\n RAISE EXCEPTION 'notifications_enqueue: tenant and template_key are required' USING ERRCODE = '22023';\n END IF;\n\n -- A redelivered event stops here, before anything is written.\n IF p_dedupe_key IS NOT NULL THEN\n SELECT q.id INTO v_existing FROM public.plg_notifications_queue q\n WHERE q.tenant_id = p_tenant_id AND q.dedupe_key = p_dedupe_key;\n IF v_existing IS NOT NULL THEN RETURN v_existing; END IF;\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_notifications_templates t\n WHERE t.tenant_id = p_tenant_id AND t.key = p_template_key AND t.is_active) THEN\n RAISE EXCEPTION 'notifications_enqueue: no active template for key %', p_template_key\n USING ERRCODE = '23503',\n HINT = 'Nothing leaves this engine unnamed \u2014 write the template first (plg_notifications_templates).';\n END IF;\n\n SELECT r.channel, r.blocked_reason INTO v_channel, v_blocked\n FROM public.notifications_resolve_channel(p_tenant_id, p_template_key, p_person_id, v_locale, p_channel) r;\n\n IF v_channel IS NULL THEN\n -- Blocked messages are RECORDED. An opt-out that produces nothing at all is\n -- indistinguishable from an engine that quietly stopped working.\n INSERT INTO public.plg_notifications_queue (\n tenant_id, unit_id, template_key, person_id, channel, locale, payload,\n scheduled_for, status, blocked_reason, dedupe_key, source_event_id)\n VALUES (p_tenant_id, p_unit_id, p_template_key, p_person_id, coalesce(p_channel, 'none'), v_locale,\n coalesce(p_payload, '{}'::jsonb), coalesce(p_scheduled_for, now()), 'blocked',\n coalesce(v_blocked, 'no channel available'), p_dedupe_key, p_source_event_id)\n RETURNING id INTO v_id;\n RETURN v_id;\n END IF;\n\n SELECT * INTO v_tpl FROM public.plg_notifications_templates t\n WHERE t.tenant_id = p_tenant_id AND t.key = p_template_key\n AND t.channel = v_channel AND t.locale = v_locale AND t.is_active\n LIMIT 1;\n\n IF v_tpl.id IS NULL THEN\n SELECT * INTO v_tpl FROM public.plg_notifications_templates t\n WHERE t.tenant_id = p_tenant_id AND t.key = p_template_key AND t.channel = v_channel AND t.is_active\n ORDER BY (t.locale = v_locale) DESC, t.locale LIMIT 1;\n END IF;\n\n IF v_tpl.id IS NULL THEN\n RAISE EXCEPTION 'notifications_enqueue: no template for key % on channel %', p_template_key, v_channel\n USING ERRCODE = '23503';\n END IF;\n\n INSERT INTO public.plg_notifications_queue (\n tenant_id, unit_id, template_key, person_id, channel, locale, purpose, payload,\n subject, body, scheduled_for, status, dedupe_key, source_event_id)\n VALUES (p_tenant_id, coalesce(p_unit_id, v_tpl.unit_id), p_template_key, p_person_id, v_channel, v_tpl.locale,\n v_tpl.purpose, coalesce(p_payload, '{}'::jsonb),\n public.notifications_render(v_tpl.subject, p_payload),\n public.notifications_render(v_tpl.body, p_payload),\n coalesce(p_scheduled_for, now()), 'pending', p_dedupe_key, p_source_event_id)\n ON CONFLICT DO NOTHING\n RETURNING id INTO v_id;\n\n IF v_id IS NULL AND p_dedupe_key IS NOT NULL THEN\n SELECT q.id INTO v_id FROM public.plg_notifications_queue q\n WHERE q.tenant_id = p_tenant_id AND q.dedupe_key = p_dedupe_key;\n END IF;\n\n RETURN v_id;\nEND $$;\n\nCREATE FUNCTION public.notifications_render(p_body text, p_payload jsonb) RETURNS text\n LANGUAGE plpgsql IMMUTABLE\n SET search_path TO ''\n AS $$\nDECLARE v_out text := coalesce(p_body, ''); k text; v text;\nBEGIN\n IF p_payload IS NULL OR jsonb_typeof(p_payload) <> 'object' THEN RETURN v_out; END IF;\n FOR k, v IN SELECT key, CASE WHEN jsonb_typeof(value) = 'string' THEN value #>> '{}' ELSE value::text END\n FROM jsonb_each(p_payload) LOOP\n v_out := replace(v_out, '{{' || k || '}}', coalesce(v, ''));\n END LOOP;\n -- A variable the payload did not carry becomes empty rather than shipping\n -- \"{{customer_name}}\" to a customer.\n RETURN regexp_replace(v_out, '\\{\\{[a-zA-Z0-9_.]+\\}\\}', '', 'g');\nEND $$;\n\nCREATE 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\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $_$\nDECLARE v_pref record; v_default text; v_optout boolean := false;\nBEGIN\n -- plugin-conversations' opt-out is absolute and is checked first: it is the\n -- record of the person telling the business to stop, and no template, purpose\n -- or preference of ours outranks it.\n IF to_regclass('public.plg_conversations_optouts') IS NOT NULL THEN\n BEGIN\n EXECUTE 'SELECT EXISTS (SELECT 1 FROM public.plg_conversations_optouts o\n WHERE o.tenant_id = $1 AND o.person_id = $2)'\n INTO v_optout USING p_tenant_id, p_person_id;\n EXCEPTION WHEN OTHERS THEN\n -- The table exists with a shape this version does not know. Not a reason\n -- to refuse to notify; it IS a reason to say so out loud.\n v_optout := false;\n RAISE NOTICE 'notifications_resolve_channel: could not read plg_conversations_optouts (%)', SQLERRM;\n END;\n END IF;\n\n IF v_optout THEN\n channel := NULL; blocked_reason := 'conversations opt-out'; RETURN;\n END IF;\n\n IF p_requested IS NOT NULL THEN\n IF EXISTS (SELECT 1 FROM public.plg_notifications_preferences p\n WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id\n AND p.channel = p_requested AND NOT p.opted_in) THEN\n channel := NULL; blocked_reason := format('recipient opted out of %s', p_requested); RETURN;\n END IF;\n channel := p_requested; blocked_reason := NULL; RETURN;\n END IF;\n\n SELECT p.channel INTO v_pref\n FROM public.plg_notifications_preferences p\n JOIN public.plg_notifications_templates t\n ON t.tenant_id = p.tenant_id AND t.key = p_key AND t.channel = p.channel\n AND t.locale = p_locale AND t.is_active\n WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id AND p.opted_in\n ORDER BY p.priority, p.channel\n LIMIT 1;\n\n IF v_pref.channel IS NOT NULL THEN\n channel := v_pref.channel; blocked_reason := NULL; RETURN;\n END IF;\n\n -- No preference is not \"no message\": the template declares the fallback.\n SELECT t.channel INTO v_default\n FROM public.plg_notifications_templates t\n WHERE t.tenant_id = p_tenant_id AND t.key = p_key AND t.locale = p_locale\n AND t.is_active AND t.is_default\n LIMIT 1;\n\n IF v_default IS NULL THEN\n SELECT t.channel INTO v_default\n FROM public.plg_notifications_templates t\n WHERE t.tenant_id = p_tenant_id AND t.key = p_key AND t.locale = p_locale AND t.is_active\n ORDER BY t.channel LIMIT 1;\n END IF;\n\n IF v_default IS NOT NULL\n AND EXISTS (SELECT 1 FROM public.plg_notifications_preferences p\n WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id\n AND p.channel = v_default AND NOT p.opted_in) THEN\n channel := NULL; blocked_reason := format('recipient opted out of %s', v_default); RETURN;\n END IF;\n\n channel := v_default;\n blocked_reason := CASE WHEN v_default IS NULL THEN 'no active template for this key' END;\nEND $_$;\n\nCREATE FUNCTION public.notifications_send_in_app(p_queue_id uuid) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $_$\nDECLARE q public.plg_notifications_queue%ROWTYPE; v_id uuid;\nBEGIN\n SELECT * INTO q FROM public.plg_notifications_queue WHERE id = p_queue_id;\n IF to_regclass('public.notifications') IS NULL THEN\n RETURN public.notifications_send_to_log(p_queue_id);\n END IF;\n EXECUTE 'INSERT INTO public.notifications (tenant_id, title, body, metadata)\n VALUES ($1, $2, $3, $4)'\n USING q.tenant_id, coalesce(q.subject, q.template_key), q.body,\n jsonb_build_object('source', 'plugin-notifications', 'queue_id', q.id, 'person_id', q.person_id);\n INSERT INTO public.plg_notifications_deliveries (tenant_id, unit_id, queue_id, channel, provider, delivered_at)\n VALUES (q.tenant_id, q.unit_id, q.id, q.channel, 'in_app', now())\n RETURNING id INTO v_id;\n RETURN jsonb_build_object('delivered', true, 'provider', 'in_app', 'delivery_id', v_id);\nEND $_$;\n\nCREATE FUNCTION public.notifications_send_to_log(p_queue_id uuid) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE q public.plg_notifications_queue%ROWTYPE; v_id uuid;\nBEGIN\n SELECT * INTO q FROM public.plg_notifications_queue WHERE id = p_queue_id;\n INSERT INTO public.plg_notifications_deliveries (tenant_id, unit_id, queue_id, channel, provider, provider_ref, delivered_at, response)\n VALUES (q.tenant_id, q.unit_id, q.id, q.channel, 'log', q.id::text || ':' || q.attempts::text, now(),\n jsonb_build_object('subject', q.subject, 'body', q.body))\n RETURNING id INTO v_id;\n RETURN jsonb_build_object('delivered', true, 'provider', 'log', 'delivery_id', v_id);\nEND $$;\n\nCREATE FUNCTION public.notifications_send_via_conversations(p_queue_id uuid) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $_$\nDECLARE q public.plg_notifications_queue%ROWTYPE; v_id uuid; v_msg uuid;\nBEGIN\n SELECT * INTO q FROM public.plg_notifications_queue WHERE id = p_queue_id;\n IF to_regclass('public.plg_conversation_messages') IS NULL THEN\n RETURN public.notifications_send_to_log(p_queue_id);\n END IF;\n BEGIN\n EXECUTE 'INSERT INTO public.plg_conversation_messages (tenant_id, direction, body, status, metadata)\n VALUES ($1, ''outbound'', $2, ''queued'', $3) RETURNING id'\n INTO v_msg USING q.tenant_id, q.body,\n jsonb_build_object('source', 'plugin-notifications', 'queue_id', q.id, 'person_id', q.person_id);\n EXCEPTION WHEN OTHERS THEN\n RETURN public.notifications_send_to_log(p_queue_id);\n END;\n INSERT INTO public.plg_notifications_deliveries (tenant_id, unit_id, queue_id, channel, provider, provider_ref, delivered_at)\n VALUES (q.tenant_id, q.unit_id, q.id, q.channel, 'conversations', v_msg::text, now())\n RETURNING id INTO v_id;\n RETURN jsonb_build_object('delivered', true, 'provider', 'conversations', 'delivery_id', v_id);\nEND $_$;\n\nCREATE FUNCTION public.plg_notifications_on_fulfillment_completed(p_event jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_ful uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_event uuid := nullif(p_event ->> 'id', '')::uuid;\n v_party uuid; v_unit uuid; v_ref text; v_person text; v_queue uuid;\nBEGIN\n IF v_tenant IS NULL OR v_ful IS NULL THEN\n RAISE EXCEPTION 'fulfillment.completed carried no tenant or subject id' USING ERRCODE = '22023';\n END IF;\n IF to_regclass('public.fulfillments') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'fulfillments not on this pool');\n END IF;\n\n SELECT o.party_id, f.unit_id, coalesce(o.reference_number, o.id::text)\n INTO v_party, v_unit, v_ref\n FROM public.fulfillments f\n LEFT JOIN public.orders o ON o.id = f.order_id\n WHERE f.id = v_ful AND f.tenant_id = v_tenant;\n\n IF v_party IS NULL THEN\n RETURN jsonb_build_object('skipped', 'delivery has no recipient');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_notifications_templates t\n WHERE t.tenant_id = v_tenant AND t.key = 'fulfillment.completed' AND t.is_active) THEN\n RETURN jsonb_build_object('skipped', 'tenant has no fulfillment.completed template');\n END IF;\n\n SELECT p.name INTO v_person FROM public.people p WHERE p.id = v_party;\n\n v_queue := public.notifications_enqueue(\n p_tenant_id => v_tenant,\n p_template_key => 'fulfillment.completed',\n p_person_id => v_party,\n p_payload => jsonb_build_object('customer_name', coalesce(v_person, ''), 'order_number', v_ref),\n p_dedupe_key => 'fulfillment.completed:' || coalesce(v_event::text, v_ful::text),\n p_unit_id => v_unit,\n p_source_event_id => v_event);\n\n RETURN jsonb_build_object('queued', v_queue, 'fulfillment_id', v_ful);\nEND $$;\n\nCREATE FUNCTION public.plg_notifications_on_invoice_due(p_event jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_inst uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_event uuid := nullif(p_event ->> 'id', '')::uuid;\n v_party uuid; v_unit uuid; v_amount numeric; v_due date; v_person text; v_queue uuid;\nBEGIN\n IF v_tenant IS NULL OR v_inst IS NULL THEN\n RAISE EXCEPTION 'invoice.due_soon carried no tenant or subject id' USING ERRCODE = '22023';\n END IF;\n IF to_regclass('public.plg_financial_invoice_installments') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'financial not on this pool');\n END IF;\n\n SELECT f.counterparty_person_id, i.unit_id, i.amount, i.due_date\n INTO v_party, v_unit, v_amount, v_due\n FROM public.plg_financial_invoice_installments i\n JOIN public.plg_financial_invoices f ON f.id = i.invoice_id\n WHERE i.id = v_inst AND i.tenant_id = v_tenant;\n\n IF v_party IS NULL THEN\n RETURN jsonb_build_object('skipped', 'instalment has no counterparty');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_notifications_templates t\n WHERE t.tenant_id = v_tenant AND t.key = 'invoice.due_soon' AND t.is_active) THEN\n RETURN jsonb_build_object('skipped', 'tenant has no invoice.due_soon template');\n END IF;\n\n SELECT p.name INTO v_person FROM public.people p WHERE p.id = v_party;\n\n v_queue := public.notifications_enqueue(\n p_tenant_id => v_tenant,\n p_template_key => 'invoice.due_soon',\n p_person_id => v_party,\n p_payload => jsonb_build_object('customer_name', coalesce(v_person, ''),\n 'amount', coalesce(v_amount, 0)::text,\n 'due_date', coalesce(v_due::text, '')),\n p_dedupe_key => 'invoice.due_soon:' || coalesce(v_event::text, v_inst::text),\n p_unit_id => v_unit,\n p_source_event_id => v_event);\n\n RETURN jsonb_build_object('queued', v_queue, 'installment_id', v_inst);\nEND $$;\n\nCREATE FUNCTION public.plg_notifications_on_order_completed(p_event jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_order uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_event uuid := nullif(p_event ->> 'id', '')::uuid;\n v_row public.orders%ROWTYPE;\n v_person text;\n v_queue uuid;\nBEGIN\n IF v_tenant IS NULL OR v_order IS NULL THEN\n RAISE EXCEPTION 'order.completed carried no tenant or subject id' USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_row FROM public.orders o WHERE o.id = v_order AND o.tenant_id = v_tenant;\n IF v_row.id IS NULL THEN\n RETURN jsonb_build_object('skipped', 'order not found');\n END IF;\n IF v_row.party_id IS NULL THEN\n -- Nobody to tell is not a failure; it is a fact worth recording once.\n RETURN jsonb_build_object('skipped', 'order has no party');\n END IF;\n\n -- A tenant that has not written the template does not get a message, and does\n -- not get an error either: the engine is opt-in per tenant, by template.\n IF NOT EXISTS (SELECT 1 FROM public.plg_notifications_templates t\n WHERE t.tenant_id = v_tenant AND t.key = 'order.completed' AND t.is_active) THEN\n RETURN jsonb_build_object('skipped', 'tenant has no order.completed template');\n END IF;\n\n SELECT p.name INTO v_person FROM public.people p WHERE p.id = v_row.party_id;\n\n v_queue := public.notifications_enqueue(\n p_tenant_id => v_tenant,\n p_template_key => 'order.completed',\n p_person_id => v_row.party_id,\n p_payload => jsonb_build_object(\n 'customer_name', coalesce(v_person, ''),\n 'order_number', coalesce(v_row.reference_number, v_row.id::text),\n 'order_total', coalesce(v_row.total, 0)::text,\n 'channel', coalesce(v_row.channel, 'balcao')),\n p_dedupe_key => 'order.completed:' || coalesce(v_event::text, v_order::text),\n p_unit_id => v_row.unit_id,\n p_source_event_id => v_event);\n\n RETURN jsonb_build_object('queued', v_queue, 'order_id', v_order);\nEND $$;\n\n\n-- \u2500\u2500 constraint \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\n\nALTER TABLE ONLY public.plg_notifications_channels\n ADD CONSTRAINT plg_notifications_channels_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_notifications_deliveries\n ADD CONSTRAINT plg_notifications_deliveries_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_notifications_deliveries\n ADD CONSTRAINT plg_notifications_deliveries_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_notifications_preferences\n ADD CONSTRAINT plg_notifications_preferences_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_notifications_preferences\n ADD CONSTRAINT plg_notifications_preferences_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_notifications_preferences\n ADD CONSTRAINT plg_notifications_preferences_tenant_id_person_id_channel_key UNIQUE (tenant_id, person_id, channel);\n\nALTER TABLE ONLY public.plg_notifications_queue\n ADD CONSTRAINT plg_notifications_queue_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_notifications_queue\n ADD CONSTRAINT plg_notifications_queue_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_notifications_templates\n ADD CONSTRAINT plg_notifications_templates_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_notifications_templates\n ADD CONSTRAINT plg_notifications_templates_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_notifications_templates\n ADD CONSTRAINT plg_notifications_templates_tenant_id_key_channel_locale_key UNIQUE (tenant_id, key, channel, locale);\n\n\n-- \u2500\u2500 view \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\u2500\n\nCREATE VIEW public.v_notification_queue WITH (security_invoker='true') AS\n SELECT tenant_id,\n template_key,\n channel,\n status,\n count(*) AS messages,\n min(scheduled_for) AS oldest,\n count(*) FILTER (WHERE ((status = 'pending'::text) AND (next_attempt_at < now()))) AS overdue,\n count(*) FILTER (WHERE (status = 'abandoned'::text)) AS abandoned,\n count(*) FILTER (WHERE (status = 'blocked'::text)) AS blocked\n FROM public.plg_notifications_queue q\n GROUP BY tenant_id, template_key, channel, status;\n\n\n-- \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\n\nCREATE UNIQUE INDEX plg_notifications_channels_uq ON public.plg_notifications_channels USING btree (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), channel);\n\nCREATE INDEX plg_notifications_deliveries_queue_idx ON public.plg_notifications_deliveries USING btree (queue_id, created_at DESC);\n\nCREATE UNIQUE INDEX plg_notifications_deliveries_ref_uq ON public.plg_notifications_deliveries USING btree (provider, provider_ref) WHERE (provider_ref IS NOT NULL);\n\nCREATE INDEX plg_notifications_deliveries_tenant_owner_idx ON public.plg_notifications_deliveries USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_notifications_deliveries_tenant_unit_idx ON public.plg_notifications_deliveries USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_notifications_preferences_tenant_owner_idx ON public.plg_notifications_preferences USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_notifications_preferences_tenant_unit_idx ON public.plg_notifications_preferences USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_notifications_queue_dedupe_uq ON public.plg_notifications_queue USING btree (tenant_id, dedupe_key) WHERE (dedupe_key IS NOT NULL);\n\nCREATE INDEX plg_notifications_queue_due_idx ON public.plg_notifications_queue USING btree (next_attempt_at) WHERE (status = 'pending'::text);\n\nCREATE INDEX plg_notifications_queue_person_idx ON public.plg_notifications_queue USING btree (tenant_id, person_id, created_at DESC);\n\nCREATE INDEX plg_notifications_queue_tenant_owner_idx ON public.plg_notifications_queue USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_notifications_queue_tenant_unit_idx ON public.plg_notifications_queue USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_notifications_templates_default_uq ON public.plg_notifications_templates USING btree (tenant_id, key, locale) WHERE is_default;\n\nCREATE INDEX plg_notifications_templates_key_idx ON public.plg_notifications_templates USING btree (tenant_id, key) WHERE is_active;\n\nCREATE INDEX plg_notifications_templates_tenant_owner_idx ON public.plg_notifications_templates USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_notifications_templates_tenant_unit_idx ON public.plg_notifications_templates USING btree (tenant_id, unit_id);\n\n\n-- \u2500\u2500 fk constraint \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\n\nALTER TABLE ONLY public.plg_notifications_channels\n ADD CONSTRAINT plg_notifications_channels_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_notifications_deliveries\n ADD CONSTRAINT plg_notifications_deliveries_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_deliveries\n ADD CONSTRAINT plg_notifications_deliveries_queue_id_fkey FOREIGN KEY (queue_id) REFERENCES public.plg_notifications_queue(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_notifications_deliveries\n ADD CONSTRAINT plg_notifications_deliveries_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_notifications_deliveries\n ADD CONSTRAINT plg_notifications_deliveries_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_preferences\n ADD CONSTRAINT plg_notifications_preferences_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_preferences\n ADD CONSTRAINT plg_notifications_preferences_person_id_fkey FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_notifications_preferences\n ADD CONSTRAINT plg_notifications_preferences_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_notifications_preferences\n ADD CONSTRAINT plg_notifications_preferences_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_queue\n ADD CONSTRAINT plg_notifications_queue_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_queue\n ADD CONSTRAINT plg_notifications_queue_person_id_fkey FOREIGN KEY (person_id) REFERENCES public.people(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_queue\n ADD CONSTRAINT plg_notifications_queue_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_notifications_queue\n ADD CONSTRAINT plg_notifications_queue_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_templates\n ADD CONSTRAINT plg_notifications_templates_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_notifications_templates\n ADD CONSTRAINT plg_notifications_templates_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_notifications_templates\n ADD CONSTRAINT plg_notifications_templates_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\n\n-- \u2500\u2500 table data \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\n\nCOPY public.plg_notifications_channels (id, tenant_id, channel, sender_function, is_active, created_at, updated_at) FROM stdin;\n4b84737c-7e5e-4f25-8936-ebf5b672435e\t\\N\tin_app\tpublic.notifications_send_in_app\tt\t2026-09-04 15:03:11.486693+00\t2026-09-04 15:03:11.486693+00\n0f2d8139-923b-4085-8e56-61da18fae029\t\\N\temail\tpublic.notifications_send_to_log\tt\t2026-09-04 15:03:11.486693+00\t2026-09-04 15:03:11.486693+00\ndb491856-6402-472b-a88e-8264d1c5f3e6\t\\N\tsms\tpublic.notifications_send_to_log\tt\t2026-09-04 15:03:11.486693+00\t2026-09-04 15:03:11.486693+00\nd98d6015-8bb6-49eb-943a-7ca384703fa9\t\\N\twhatsapp\tpublic.notifications_send_via_conversations\tt\t2026-09-04 15:03:11.486693+00\t2026-09-04 15:03:11.486693+00\nc712737e-7edc-48f4-8a0f-5a5455a6c50e\t\\N\tpush\tpublic.notifications_send_to_log\tt\t2026-09-04 15:03:11.486693+00\t2026-09-04 15:03:11.486693+00\n\\.\n\n\n-- \u2500\u2500 trigger \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\n\nCREATE TRIGGER plg_notifications_channels_updated_at BEFORE UPDATE ON public.plg_notifications_channels FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_notifications_deliveries_updated_at BEFORE UPDATE ON public.plg_notifications_deliveries FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_notifications_preferences_updated_at BEFORE UPDATE ON public.plg_notifications_preferences FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_notifications_queue_updated_at BEFORE UPDATE ON public.plg_notifications_queue FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_notifications_templates_updated_at BEFORE UPDATE ON public.plg_notifications_templates FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\n\n-- \u2500\u2500 row security \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\n\nALTER TABLE public.plg_notifications_channels ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_notifications_deliveries ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_notifications_preferences ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_notifications_queue ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_notifications_templates ENABLE ROW LEVEL SECURITY;\n\n\n-- \u2500\u2500 policy \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\n\nCREATE POLICY plg_notifications_channels_read ON public.plg_notifications_channels FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR (tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids))));\n\nCREATE POLICY plg_notifications_deliveries_authz_delete ON public.plg_notifications_deliveries FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_deliveries.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.delivery'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.delivery'::text, plg_notifications_deliveries.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_deliveries.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.delete'::text, plg_notifications_deliveries.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_deliveries_authz_insert ON public.plg_notifications_deliveries FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_deliveries.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.create'::text, plg_notifications_deliveries.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_deliveries_authz_select ON public.plg_notifications_deliveries FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_deliveries.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.delivery'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.delivery'::text, plg_notifications_deliveries.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_deliveries.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.read'::text, plg_notifications_deliveries.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_deliveries_authz_update ON public.plg_notifications_deliveries FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_deliveries.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.delivery'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.delivery'::text, plg_notifications_deliveries.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_deliveries.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_deliveries.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_deliveries.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_deliveries.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_preferences_authz_delete ON public.plg_notifications_preferences FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_preferences.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.preference'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.preference'::text, plg_notifications_preferences.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_preferences.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.delete'::text, plg_notifications_preferences.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_preferences_authz_insert ON public.plg_notifications_preferences FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_preferences.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.create'::text, plg_notifications_preferences.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_preferences_authz_select ON public.plg_notifications_preferences FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_preferences.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.preference'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.preference'::text, plg_notifications_preferences.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_preferences.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.read'::text, plg_notifications_preferences.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_preferences_authz_update ON public.plg_notifications_preferences FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_preferences.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.preference'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.preference'::text, plg_notifications_preferences.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_preferences.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_preferences.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_preferences.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_preferences.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_queue_authz_delete ON public.plg_notifications_queue FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_queue.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.message'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.message'::text, plg_notifications_queue.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_queue.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.delete'::text, plg_notifications_queue.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_queue_authz_insert ON public.plg_notifications_queue FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_queue.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.create'::text, plg_notifications_queue.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_queue_authz_select ON public.plg_notifications_queue FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_queue.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.message'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.message'::text, plg_notifications_queue.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_queue.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.read'::text, plg_notifications_queue.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_queue_authz_update ON public.plg_notifications_queue FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_queue.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.message'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.message'::text, plg_notifications_queue.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_queue.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_queue.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_queue.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_queue.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_templates_authz_delete ON public.plg_notifications_templates FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_templates.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.template'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.template'::text, plg_notifications_templates.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_templates.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.delete'::text, plg_notifications_templates.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_templates_authz_insert ON public.plg_notifications_templates FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_templates.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.create'::text, plg_notifications_templates.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_templates_authz_select ON public.plg_notifications_templates FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_templates.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.template'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.template'::text, plg_notifications_templates.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_templates.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.read'::text, plg_notifications_templates.unit_id) AS has_permission)));\n\nCREATE POLICY plg_notifications_templates_authz_update ON public.plg_notifications_templates FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_templates.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('notifications.template'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('notifications.template'::text, plg_notifications_templates.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('notifications.read_all'::text, plg_notifications_templates.unit_id) AS has_permission)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_templates.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_notifications_templates.unit_id) AS has_unit)) AND ( SELECT app.has_permission('notifications.edit'::text, plg_notifications_templates.unit_id) AS has_permission)));\n\n\n-- \u2500\u2500 acl \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\u2500\u2500\n\nREVOKE ALL ON FUNCTION public.notifications_drain(p_limit integer) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.notifications_drain(p_limit integer) TO service_role;\n\nREVOKE ALL ON FUNCTION public.notifications_enqueue(p_tenant_id uuid, p_template_key text, p_person_id uuid, p_payload jsonb, p_dedupe_key text, p_scheduled_for timestamp with time zone, p_channel text, p_locale text, p_unit_id uuid, p_source_event_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.notifications_enqueue(p_tenant_id uuid, p_template_key text, p_person_id uuid, p_payload jsonb, p_dedupe_key text, p_scheduled_for timestamp with time zone, p_channel text, p_locale text, p_unit_id uuid, p_source_event_id uuid) TO authenticated;\nGRANT ALL ON FUNCTION public.notifications_enqueue(p_tenant_id uuid, p_template_key text, p_person_id uuid, p_payload jsonb, p_dedupe_key text, p_scheduled_for timestamp with time zone, p_channel text, p_locale text, p_unit_id uuid, p_source_event_id uuid) TO service_role;\n\nGRANT ALL ON FUNCTION public.notifications_render(p_body text, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.notifications_render(p_body text, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.notifications_resolve_channel(p_tenant_id uuid, p_key text, p_person_id uuid, p_locale text, p_requested text, OUT channel text, OUT blocked_reason text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.notifications_resolve_channel(p_tenant_id uuid, p_key text, p_person_id uuid, p_locale text, p_requested text, OUT channel text, OUT blocked_reason text) TO authenticated;\nGRANT ALL ON FUNCTION public.notifications_resolve_channel(p_tenant_id uuid, p_key text, p_person_id uuid, p_locale text, p_requested text, OUT channel text, OUT blocked_reason text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.notifications_send_in_app(p_queue_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.notifications_send_in_app(p_queue_id uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.notifications_send_to_log(p_queue_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.notifications_send_to_log(p_queue_id uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.notifications_send_via_conversations(p_queue_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.notifications_send_via_conversations(p_queue_id uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_notifications_on_fulfillment_completed(p_event jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_notifications_on_fulfillment_completed(p_event jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_notifications_on_invoice_due(p_event jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_notifications_on_invoice_due(p_event jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_notifications_on_order_completed(p_event jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_notifications_on_order_completed(p_event jsonb) TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_notifications_channels TO anon;\nGRANT SELECT,REFERENCES,TRIGGER,TRUNCATE,MAINTAIN ON TABLE public.plg_notifications_channels TO authenticated;\nGRANT ALL ON TABLE public.plg_notifications_channels TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_notifications_deliveries TO anon;\nGRANT ALL ON TABLE public.plg_notifications_deliveries TO authenticated;\nGRANT ALL ON TABLE public.plg_notifications_deliveries TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_notifications_preferences TO anon;\nGRANT ALL ON TABLE public.plg_notifications_preferences TO authenticated;\nGRANT ALL ON TABLE public.plg_notifications_preferences TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_notifications_queue TO anon;\nGRANT ALL ON TABLE public.plg_notifications_queue TO authenticated;\nGRANT ALL ON TABLE public.plg_notifications_queue TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_notifications_templates TO anon;\nGRANT ALL ON TABLE public.plg_notifications_templates TO authenticated;\nGRANT ALL ON TABLE public.plg_notifications_templates TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_notification_queue TO anon;\nGRANT ALL ON TABLE public.v_notification_queue TO authenticated;\nGRANT ALL ON TABLE public.v_notification_queue TO service_role;\n\n\n-- \u2500\u2500 comment \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\n\nCOMMENT ON FUNCTION public.notifications_drain(p_limit integer) IS 'Delivers what is due (PRD 03). A separate job from consume_event_log() on purpose: the provider is outside and the queue is ours, so a provider timing out costs a retry here and never holds up the event log.';\n\nCOMMENT ON FUNCTION public.notifications_enqueue(p_tenant_id uuid, p_template_key text, p_person_id uuid, p_payload jsonb, p_dedupe_key text, p_scheduled_for timestamp with time zone, p_channel text, p_locale text, p_unit_id uuid, p_source_event_id uuid) IS 'Put a message in the queue (PRD 03). Refuses an unknown template, records a veto as a blocked row with its reason, and returns the EXISTING id when the dedupe key has been seen \u2014 so a redelivered event costs nothing.';\n\nCOMMENT ON FUNCTION public.notifications_render(p_body text, p_payload jsonb) IS 'Substitutes {{variable}} from the payload (PRD 03). An unresolved variable becomes empty \u2014 shipping the placeholder itself to a customer is the failure this last line prevents.';\n\nCOMMENT ON TABLE public.plg_notifications_channels IS 'Which function delivers a channel, per tenant, with a platform default when tenant_id is NULL (PRD 03). The drain calls it by name, so a provider is swapped by a row rather than by a migration.';\n\nCOMMENT ON TABLE public.plg_notifications_deliveries IS 'What the provider said (PRD 03). Separate from the queue because the queue is ours and the provider is not: a provider failing must cost a retry here, never block public.consume_event_log().';\n\nCOMMENT ON TABLE public.plg_notifications_preferences IS 'How a person wants to be reached (PRD 03). The recipient picks the channel; the sender only picks the message. `opted_in = false` is a veto the engine cannot override \u2014 plugin-conversations'' own opt-out is a second, absolute one.';\n\nCOMMENT ON TABLE public.plg_notifications_queue IS 'What is waiting to go out, what went out and what was refused (PRD 03). Filled by event subscribers, never by a direct call \u2014 a notification only ANNOUNCES, so by the phase''s ownership rule it is an event. dedupe_key is what stops a redelivered event sending twice.';\n\nCOMMENT ON COLUMN public.plg_notifications_queue.blocked_reason IS 'Why this message was not sent, in words (PRD 03 acceptance). An opt-out that produces no row at all is indistinguishable from a bug.';\n\nCOMMENT ON TABLE public.plg_notifications_templates IS 'What a notification says, per key, channel and locale (PRD 03). Enqueuing with a key that has no template is refused \u2014 nothing leaves this system unnamed. `is_default` marks the channel used when the recipient has no preference.';\n\nCOMMENT ON VIEW public.v_notification_queue IS 'The notification queue at a glance (PRD 03): what is waiting, what is overdue, what was abandoned and what was refused. Visible abandonment is only visible if there is somewhere to look.';\n\n\n-- \u2500\u2500 default privileges: restore \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\n-- Put back, so the NEXT thing installed inherits what the chain had.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEND $dp$;\n";
2
2
  export declare const MIGRATION_001_O_HISTORICO_DIZ_POR_QUE_NAO_CHEGOU = "-- 001_o_historico_diz_por_que_nao_chegou.sql\n--\n-- \"Meus lembretes n\u00E3o est\u00E3o chegando\" \u00E9 a pergunta que o V1 responde e o V2\n-- n\u00E3o. L\u00E1 existe uma tela de Hist\u00F3rico de Disparos com tr\u00EAs totais e uma\n-- coluna que, nas linhas de falha, traz o MOTIVO em vez do texto enviado. Na\n-- verifica\u00E7\u00E3o do guia: 500 disparos, 390 enviados, **110 falhas** \u2014 todas pelo\n-- mesmo motivo, template n\u00E3o aprovado pela Meta. Sem essa tela, ningu\u00E9m teria\n-- descoberto isso; a queixa continuaria sendo \"o sistema n\u00E3o avisa o cliente\".\n--\n-- Aqui o motor j\u00E1 registrava tudo o que a resposta pede \u2014 `last_error`,\n-- `blocked_reason`, `attempts`, e a `error` de cada tentativa de entrega. O que\n-- n\u00E3o existia era **uma leitura por mensagem**: a \u00FAnica view do plugin\n-- (`v_notification_queue`) \u00E9 agregada, boa para saber se a fila est\u00E1 saud\u00E1vel\n-- e in\u00FAtil para saber por que a Maria n\u00E3o recebeu.\n--\n-- E CORRIGE UM DEFEITO DO V1 DE PROP\u00D3SITO. L\u00E1, toda linha de falha mostra\n-- `Cliente #null` \u2014 o registro perde a refer\u00EAncia de quem era, ent\u00E3o a tela\n-- diz que 110 pessoas ficaram sem aviso e n\u00E3o diz QUAIS. Aqui o nome vem por\n-- jun\u00E7\u00E3o com `people`: uma lista de falhas que n\u00E3o nomeia ningu\u00E9m n\u00E3o permite\n-- ligar para ningu\u00E9m, e ligar \u00E9 a \u00FAnica coisa \u00FAtil a fazer com ela.\n--\n-- MOTIVO TEM PRECED\u00CANCIA, E A ORDEM IMPORTA: `blocked_reason` primeiro. Uma\n-- mensagem bloqueada (o destinat\u00E1rio optou por n\u00E3o receber, est\u00E1 em hor\u00E1rio de\n-- sil\u00EAncio, n\u00E3o h\u00E1 canal) nunca chegou a ser tentada, e mostrar o erro da\n-- \u00FAltima tentativa ali diria que o envio falhou quando ele nem come\u00E7ou.\n\nCREATE OR REPLACE VIEW public.v_notification_history WITH (security_invoker='true') AS\nSELECT\n q.id,\n q.tenant_id,\n q.unit_id,\n q.template_key,\n q.channel,\n q.status,\n q.purpose,\n q.locale,\n q.scheduled_for,\n q.created_at,\n q.attempts,\n q.max_attempts,\n q.person_id,\n -- O que o V1 perde. Sem nome, a lista de falhas \u00E9 um n\u00FAmero.\n p.name AS person_name,\n p.phone AS person_phone,\n p.email AS person_email,\n q.subject,\n q.body,\n -- Bloqueado antes de tentar vem primeiro: o erro da \u00FAltima tentativa ali\n -- diria que o envio falhou quando ele nem chegou a come\u00E7ar.\n coalesce(q.blocked_reason, q.last_error, d.error) AS reason,\n d.provider,\n d.provider_ref,\n d.delivered_at,\n d.failed_at\nFROM public.plg_notifications_queue q\nLEFT JOIN public.people p ON p.id = q.person_id\n-- A \u00DALTIMA tentativa, n\u00E3o todas: uma mensagem com cinco tentativas viraria\n-- cinco linhas no hist\u00F3rico, e o lojista conta cinco falhas onde houve uma\n-- mensagem.\nLEFT JOIN LATERAL (\n SELECT x.error, x.provider, x.provider_ref, x.delivered_at, x.failed_at\n FROM public.plg_notifications_deliveries x\n WHERE x.queue_id = q.id\n ORDER BY x.created_at DESC\n LIMIT 1\n) d ON true;\n\nCOMMENT ON VIEW public.v_notification_history IS\n 'Uma linha por mensagem, com o MOTIVO de n\u00E3o ter chegado e o NOME de quem ficou sem \u2014 o V1 mostra \"Cliente #null\" em toda linha de falha (notifications/001).';\n\nGRANT SELECT ON public.v_notification_history TO authenticated;\nGRANT SELECT ON public.v_notification_history TO service_role;\n\n-- \u2500\u2500 os tr\u00EAs totais do topo \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\n--\n-- O V1 mostra Total / Enviados / Falhas. Aqui s\u00E3o quatro, porque BLOQUEADO n\u00E3o\n-- \u00E9 FALHA: a mensagem que n\u00E3o saiu porque a pessoa pediu para n\u00E3o receber \u00E9 o\n-- sistema funcionando, e som\u00E1-la \u00E0s falhas faria o lojista ca\u00E7ar um defeito que\n-- n\u00E3o existe.\nCREATE OR REPLACE FUNCTION public.notifications_history_summary(p_days integer DEFAULT 30)\nRETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n STABLE\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_out jsonb;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'notifications_history_summary: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('notifications.read', NULL::uuid) THEN\n RAISE EXCEPTION 'notifications_history_summary: exige notifications.read' USING ERRCODE = '42501';\n END IF;\n\n SELECT jsonb_build_object(\n 'total', count(*),\n 'sent', count(*) FILTER (WHERE h.status = 'sent'),\n 'failed', count(*) FILTER (WHERE h.status IN ('failed', 'abandoned')),\n 'blocked', count(*) FILTER (WHERE h.status = 'blocked'),\n 'pending', count(*) FILTER (WHERE h.status = 'pending'),\n 'days', p_days,\n -- O motivo mais comum, e quantas vezes. \u00C9 o que transforma \"110 falhas\" em\n -- \"110 falhas pela MESMA raz\u00E3o\" \u2014 que \u00E9 a diferen\u00E7a entre um problema e\n -- cento e dez.\n 'topReason', (\n SELECT jsonb_build_object('reason', r.reason, 'count', r.n)\n FROM (\n SELECT coalesce(x.reason, '\u2014') AS reason, count(*) AS n\n FROM public.v_notification_history x\n WHERE x.tenant_id = v_tenant\n AND x.status IN ('failed', 'abandoned', 'blocked')\n AND x.created_at >= now() - make_interval(days => p_days)\n GROUP BY 1 ORDER BY n DESC LIMIT 1\n ) r)\n ) INTO v_out\n FROM public.v_notification_history h\n WHERE h.tenant_id = v_tenant\n AND h.created_at >= now() - make_interval(days => p_days);\n\n RETURN v_out;\nEND $$;\n\nCOMMENT ON FUNCTION public.notifications_history_summary(integer) IS\n 'Total, enviados, falhas, bloqueados e o motivo mais comum. Bloqueado N\u00C3O conta como falha \u2014 \u00E9 o sistema funcionando (notifications/001).';\n\nREVOKE ALL ON FUNCTION public.notifications_history_summary(integer) FROM PUBLIC, anon;\nGRANT EXECUTE ON FUNCTION public.notifications_history_summary(integer) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.notifications_history_summary(integer) TO service_role;\n";
3
+ export declare const MIGRATION_002_THE_OPT_OUT_IS_CHECKED_BY_PHONE = "-- 002_the_opt_out_is_checked_by_phone.sql\n--\n-- THE OPT-OUT WAS NEVER CHECKED, AND SAID SO ONLY IN A NOTICE.\n--\n-- `notifications_resolve_channel` opens with the strongest comment in the\n-- plugin: the opt-out \"is absolute and is checked first \u2026 no template, purpose\n-- or preference of ours outranks it.\" It then read a column that does not\n-- exist. `plg_conversations_optouts` is keyed by `phone_e164` \u2014 there is no\n-- `person_id` on it and there never was, because the record arrives from a\n-- WhatsApp webhook that knows a phone number and nothing else.\n--\n-- The query therefore raised 42703 on EVERY call. A blanket\n-- `EXCEPTION WHEN OTHERS` caught it, set `v_optout := false` and continued. So\n-- the absolute check returned \"not opted out\" for everybody, always, and the\n-- only trace was a NOTICE nobody reads.\n--\n-- Two things are wrong there and both are fixed here.\n--\n-- 1. THE LOOKUP. The opt-out is about a phone; the queue targets a person. The\n-- bridge is `people.phone`, which is free text \u2014 a direct comparison against\n-- an E.164 column would match almost nothing and would be the same silent\n-- failure wearing a different coat. `notifications_phone_key` reduces both\n-- sides to the same national digits, following the convention\n-- `plg_conversations_match_by_phone` already established for this exact\n-- problem: strip everything that is not a digit, drop a leading country\n-- code, and repair a legacy 10-digit Brazilian mobile by restoring the 9.\n-- It compares the WHOLE national number rather than `right(digits, 10)`,\n-- because the last ten digits of an 11-digit national number drop the first\n-- digit of the area code and collide across DDDs \u2014 11 and 21 produce the\n-- same key. For matching a thread that is a tolerable bet; for deciding\n-- whether someone asked to be left alone it is not.\n--\n-- 2. THE FAILURE DIRECTION. Consent fails CLOSED now. If the table is there and\n-- the read fails for a reason this version does not understand, the message\n-- is blocked with a reason rather than sent. A blocked message is a row on\n-- the history screen with the reason next to it, and somebody can act on it;\n-- a message sent to someone who asked to stop cannot be recalled. The\n-- `to_regclass` guard stays as it was: a pool without plugin-conversations\n-- has no opt-out records, which is an answer, not an error.\n--\n-- What is deliberately NOT changed: the check stays ABSOLUTE rather than\n-- becoming per-channel. `plg_conversations_optouts.channel` would allow \"he\n-- stopped WhatsApp, e-mail is still fine\", and that may well be what the\n-- business wants \u2014 but it loosens consent, and loosening consent is a product\n-- decision, not a bug fix. Over-blocking is the safe direction to be wrong in.\n--\n-- Scope of the damage until now: the queue's `whatsapp` sender only writes a\n-- `queued` row into `plg_conversation_messages` and nothing in the repo drains\n-- it, and the edge function `messaging-send` checks the opt-out correctly by\n-- phone on its own path. So this was a live hole in the QUEUE, which is exactly\n-- what any future outbound cadence would be built on.\n\nSET check_function_bodies = false;\n\n-- \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\n\n-- The comparable form of a phone number: national digits, country code gone,\n-- legacy mobile repaired. NULL when the input cannot be a phone number, so that\n-- a person with no phone can never accidentally equal an opt-out row.\nCREATE OR REPLACE FUNCTION public.notifications_phone_key(p_phone text)\nRETURNS text\nLANGUAGE plpgsql IMMUTABLE\nSET search_path TO ''\nAS $$\nDECLARE d text; national text;\nBEGIN\n d := regexp_replace(coalesce(p_phone, ''), '[^0-9]', '', 'g');\n IF d = '' THEN RETURN NULL; END IF;\n\n -- A leading 55 is the country code only when something remains that could be\n -- a national number; '5511' is a landline in DDD 55, not a country code.\n national := CASE WHEN length(d) > 11 AND left(d, 2) = '55'\n THEN right(d, length(d) - 2)\n ELSE d END;\n\n -- The TRUNK PREFIX. '(011) 99888-7777' is how half of Brazil still writes a\n -- number, and keeping the 0 made it a different key from the E.164 the\n -- opt-out stores \u2014 a silent MISS, which on a consent check means the message\n -- goes out. Stripped after the country code, because '055...' exists too.\n IF length(national) IN (11, 12) AND left(national, 1) = '0' THEN\n national := right(national, length(national) - 1);\n END IF;\n\n -- Restore the ninth digit on a pre-2016 mobile so that the same person\n -- written both ways resolves to one key.\n --\n -- KNOWN AMBIGUITY, not solved here: a ten-digit foreign number stored with\n -- no country code \u2014 '(917) 555-1234' \u2014 is indistinguishable from a Brazilian\n -- one and gets repaired into DDD 91. In a Brazilian product a bare national\n -- number IS Brazilian; storing a US number without its +1 is already lossy.\n -- The failure needs a real opt-out on that exact DDD-91 number to bite.\n IF length(national) = 10 AND substr(national, 3, 1) ~ '^[6-9]$' THEN\n national := left(national, 2) || '9' || right(national, 8);\n END IF;\n\n -- Shorter than eight digits is not a phone number; returning it would let a\n -- truncated string match a real opt-out. Longer than eleven is not one\n -- either \u2014 it is two numbers, or a number with an extension glued on, and\n -- `notifications_phone_keys` is what takes those apart.\n IF length(national) < 8 OR length(national) > 11 THEN RETURN NULL; END IF;\n RETURN national;\nEND $$;\n\n-- Every number a free-text field might be carrying.\n--\n-- `people.phone` has no format and no normalising trigger. Real rows hold\n-- '(11) 99888-7777 / (11) 3333-4444' and '(11) 99888-7777 ramal 12', and\n-- comparing the whole string to an E.164 opt-out misses both \u2014 the person asked\n-- to stop and the message goes out anyway.\n--\n-- So the left-hand side is a SET. The regex takes runs that could be a number\n-- (digits and the punctuation people put between them) and stops at anything\n-- else \u2014 a slash, the word \"ramal\" \u2014 which is what separates one number from\n-- the next.\nCREATE OR REPLACE FUNCTION public.notifications_phone_keys(p_phone text)\nRETURNS text[]\nLANGUAGE sql IMMUTABLE\nSET search_path TO ''\nAS $$\n SELECT coalesce(\n array_agg(DISTINCT k) FILTER (WHERE k IS NOT NULL),\n '{}'::text[]\n )\n FROM (\n SELECT public.notifications_phone_key(m[1]) AS k\n FROM regexp_matches(coalesce(p_phone, ''), '\\+?[0-9][0-9\\s().-]{6,}', 'g') AS m\n ) s;\n$$;\n\nCOMMENT ON FUNCTION public.notifications_phone_keys(text) IS\n 'Every comparable phone key a free-text contact field carries. One field can hold two numbers.';\n\nREVOKE ALL ON FUNCTION public.notifications_phone_keys(text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.notifications_phone_keys(text) TO service_role;\n\nCOMMENT ON FUNCTION public.notifications_phone_key(text) IS\n 'National digits of a phone number, for comparing a free-text people.phone against an E.164 opt-out record.';\n\nREVOKE ALL ON FUNCTION public.notifications_phone_key(text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.notifications_phone_key(text) TO service_role;\n\nCREATE 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\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $_$\nDECLARE v_pref record; v_default text; v_optout boolean := false; v_phone_keys text[];\nBEGIN\n -- plugin-conversations' opt-out is absolute and is checked first: it is the\n -- record of the person telling the business to stop, and no template, purpose\n -- or preference of ours outranks it.\n --\n -- Matched by PHONE, because that is the only thing the opt-out carries. See\n -- the header of this migration for why the previous person_id comparison\n -- could never have matched and why it failed quietly.\n IF to_regclass('public.plg_conversations_optouts') IS NOT NULL THEN\n BEGIN\n SELECT public.notifications_phone_keys(pe.phone) INTO v_phone_keys\n FROM public.people pe\n WHERE pe.id = p_person_id;\n\n -- The query runs even when the person has NO phone, on purpose. Guarding\n -- it on a non-empty key set meant a table whose shape this version does\n -- not understand was only ever detected for people who happened to have a\n -- number \u2014 everybody else sailed past the broken check and was sent to.\n -- An empty array simply matches nothing.\n --\n -- `tenant_id IS NULL` is load-bearing: `tyxter-webhook` writes every\n -- opt-out with a null tenant, so a tenant-only comparison would match\n -- none of the records that actually exist.\n EXECUTE 'SELECT EXISTS (\n SELECT 1 FROM public.plg_conversations_optouts o\n WHERE (o.tenant_id = $1 OR o.tenant_id IS NULL)\n AND public.notifications_phone_key(o.phone_e164) = ANY ($2))'\n INTO v_optout USING p_tenant_id, coalesce(v_phone_keys, '{}'::text[]);\n EXCEPTION WHEN OTHERS THEN\n -- RAISE, do not block.\n --\n -- Fail-closed was right; writing a blocked ROW to achieve it was not.\n -- `notifications_enqueue` stores the caller's `dedupe_key` on that row and\n -- short-circuits on it forever after, so one transient read failure\n -- destroyed that message permanently \u2014 including an invoice-due or an\n -- order confirmation, whose dedupe keys are per-event and never come\n -- round again.\n --\n -- Raising instead leaves the delivery to `consume_event_log`, which\n -- already retries with backoff and abandons visibly after max_attempts.\n -- Nothing is sent either way; only one of the two is recoverable.\n RAISE EXCEPTION 'notifications_resolve_channel: could not read plg_conversations_optouts (%)', SQLERRM\n USING ERRCODE = '55000';\n END;\n END IF;\n\n IF v_optout THEN\n channel := NULL; blocked_reason := 'conversations opt-out'; RETURN;\n END IF;\n\n IF p_requested IS NOT NULL THEN\n IF EXISTS (SELECT 1 FROM public.plg_notifications_preferences p\n WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id\n AND p.channel = p_requested AND NOT p.opted_in) THEN\n channel := NULL; blocked_reason := format('recipient opted out of %s', p_requested); RETURN;\n END IF;\n channel := p_requested; blocked_reason := NULL; RETURN;\n END IF;\n\n SELECT p.channel INTO v_pref\n FROM public.plg_notifications_preferences p\n JOIN public.plg_notifications_templates t\n ON t.tenant_id = p.tenant_id AND t.key = p_key AND t.channel = p.channel\n AND t.locale = p_locale AND t.is_active\n WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id AND p.opted_in\n ORDER BY p.priority, p.channel\n LIMIT 1;\n\n IF v_pref.channel IS NOT NULL THEN\n channel := v_pref.channel; blocked_reason := NULL; RETURN;\n END IF;\n\n -- No preference is not \"no message\": the template declares the fallback.\n SELECT t.channel INTO v_default\n FROM public.plg_notifications_templates t\n WHERE t.tenant_id = p_tenant_id AND t.key = p_key AND t.locale = p_locale\n AND t.is_active AND t.is_default\n LIMIT 1;\n\n IF v_default IS NULL THEN\n SELECT t.channel INTO v_default\n FROM public.plg_notifications_templates t\n WHERE t.tenant_id = p_tenant_id AND t.key = p_key AND t.locale = p_locale AND t.is_active\n ORDER BY t.channel LIMIT 1;\n END IF;\n\n IF v_default IS NOT NULL\n AND EXISTS (SELECT 1 FROM public.plg_notifications_preferences p\n WHERE p.tenant_id = p_tenant_id AND p.person_id = p_person_id\n AND p.channel = v_default AND NOT p.opted_in) THEN\n channel := NULL; blocked_reason := format('recipient opted out of %s', v_default); RETURN;\n END IF;\n\n channel := v_default;\n blocked_reason := CASE WHEN v_default IS NULL THEN 'no active template for this key' END;\nEND $_$;\n\n-- \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\n\n-- The lookup is `notifications_phone_key(o.phone_e164) = ANY(...)`, which no\n-- existing index can serve: without this it is a sequential scan of the whole\n-- opt-out table on EVERY enqueue, and because `tyxter-webhook` writes every\n-- record with a null tenant, that table is cluster-wide and only grows.\n--\n-- Guarded: plugin-conversations owns the table and a pool may not have it.\nDO $$\nBEGIN\n IF to_regclass('public.plg_conversations_optouts') IS NOT NULL THEN\n EXECUTE 'CREATE INDEX IF NOT EXISTS plg_conversations_optouts_phone_key_idx\n ON public.plg_conversations_optouts (public.notifications_phone_key(phone_e164))';\n END IF;\nEND $$;\n";
3
4
  export declare const MIGRATIONS: Array<{
4
5
  id: string;
5
6
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,o3uEA0hClC,CAAA;AAED,eAAO,MAAM,gDAAgD,iwMAmI5D,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAGzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,o3uEA0hClC,CAAA;AAED,eAAO,MAAM,gDAAgD,iwMAmI5D,CAAA;AAED,eAAO,MAAM,6CAA6C,s+aAkQzD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAIzD,CAAA"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "status": "beta",
5
5
  "dependencies": []
6
6
  },
7
- "version": "0.2.2",
7
+ "version": "0.2.4",
8
8
  "description": "Fayz SDK — plugin-notifications plugin",
9
9
  "type": "module",
10
10
  "sideEffects": false,
@@ -26,9 +26,9 @@
26
26
  "lucide-react": ">=0.400.0 <1.0.0"
27
27
  },
28
28
  "dependencies": {
29
- "@fayz-ai/core": "^0.22.0",
30
- "@fayz-ai/ui": "^0.22.0",
31
- "@fayz-ai/admin": "^0.22.0"
29
+ "@fayz-ai/core": "^0.23.0",
30
+ "@fayz-ai/ui": "^0.23.0",
31
+ "@fayz-ai/admin": "^0.23.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/react": "^18.3.0",