@fayz-ai/plugin-getting-started 0.2.0-canary.20261028153856 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/migrations/index.ts"],"names":[],"mappings":";AAIO,IAAM,qBAAA,GAAwB,CAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqJ9B,IAAM,yBAAA,GAA4B,CAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA0TlC,IAAM,+BAAA,GAAkC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkQxC,IAAM,wDAAA,GAA2D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0MjE,IAAM,iCAAA,GAAoC,CAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA8D1C,IAAM,wCAAA,GAA2C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4GjD,IAAM,wCAAA,GAA2C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAuOjD,IAAM,UAAA,GAAiD;AAAA,EAC5D,EAAE,EAAA,EAAI,aAAA,EAAe,GAAA,EAAK,qBAAA,EAAsB;AAAA,EAChD,EAAE,EAAA,EAAI,iBAAA,EAAmB,GAAA,EAAK,yBAAA,EAA0B;AAAA,EACxD,EAAE,EAAA,EAAI,uBAAA,EAAyB,GAAA,EAAK,+BAAA,EAAgC;AAAA,EACpE,EAAE,EAAA,EAAI,gDAAA,EAAkD,GAAA,EAAK,wDAAA,EAAyD;AAAA,EACtH,EAAE,EAAA,EAAI,yBAAA,EAA2B,GAAA,EAAK,iCAAA,EAAkC;AAAA,EACxE,EAAE,EAAA,EAAI,gCAAA,EAAkC,GAAA,EAAK,wCAAA,EAAyC;AAAA,EACtF,EAAE,EAAA,EAAI,gCAAA,EAAkC,GAAA,EAAK,wCAAA;AAC/C","file":"main.js","sourcesContent":["// AUTO-GENERATED from 000_journey.sql, 001_trial_clock.sql, 002_platform_operator.sql, 003_the_clock_starts_once_and_only_for_who_may.sql, 004_lead_keeps_the_name.sql, 005_qa_leads_go_to_qa_platform.sql, 006_the_lead_is_born_at_signup.sql — regenerate with scripts/embed-migrations.mjs\n// SQL files are the source of truth; this inline copy lets the manifest declare\n// migrations as data. Do not edit by hand — run the embed script instead.\n\nexport const MIGRATION_000_JOURNEY = `-- 000_journey.sql — the getting-started plugin keeps the journey; tenants.trial_ends_at stays the end of the trial.\n\n-- ── platform config (one row; only service_role/postgres write) ───────────────\nCREATE TABLE IF NOT EXISTS public.plg_getting_started_config (\n id boolean PRIMARY KEY DEFAULT true CHECK (id),\n trial_days integer NOT NULL DEFAULT 14 CHECK (trial_days BETWEEN 1 AND 90),\n -- Documents the spine DEFAULT (db 044); never changes it.\n setup_window_days integer NOT NULL DEFAULT 30,\n crm_tenant_slug text NOT NULL DEFAULT 'controlgroup',\n reminder_hour_utc integer NOT NULL DEFAULT 13 CHECK (reminder_hour_utc BETWEEN 0 AND 23),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nINSERT INTO public.plg_getting_started_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING;\nALTER TABLE public.plg_getting_started_config ENABLE ROW LEVEL SECURITY;\nREVOKE ALL ON public.plg_getting_started_config FROM PUBLIC, anon, authenticated;\n-- Read by the view and the RPCs; no write policy on purpose.\nGRANT SELECT ON public.plg_getting_started_config TO authenticated;\nDROP POLICY IF EXISTS plg_getting_started_config_read ON public.plg_getting_started_config;\nCREATE POLICY plg_getting_started_config_read ON public.plg_getting_started_config\n FOR SELECT TO authenticated USING (true);\n\n-- ── the journey: one row per tenant ───────────────────────────────────────────\nCREATE TABLE IF NOT EXISTS public.plg_getting_started_journeys (\n tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,\n ready_at timestamptz,\n trial_started_at timestamptz,\n trial_days integer,\n converted_at timestamptz,\n wants_contract_at timestamptz,\n source text,\n utm jsonb NOT NULL DEFAULT '{}'::jsonb,\n answers_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_getting_started_journeys ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_getting_started_journeys FORCE ROW LEVEL SECURITY;\n-- Writes go through the RPCs only.\nGRANT SELECT ON public.plg_getting_started_journeys TO authenticated;\nDROP POLICY IF EXISTS plg_getting_started_journeys_select ON public.plg_getting_started_journeys;\nCREATE POLICY plg_getting_started_journeys_select ON public.plg_getting_started_journeys\n FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n\n-- ── timeline + idempotency lock (one row per tenant, kind and day) ────────────\nCREATE TABLE IF NOT EXISTS public.plg_getting_started_events (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n kind text NOT NULL CHECK (kind ~ '^[a-z][a-z0-9_]*$'),\n day date NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')::date,\n channel text,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n actor_id uuid,\n created_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE UNIQUE INDEX IF NOT EXISTS plg_getting_started_events_once_a_day\n ON public.plg_getting_started_events (tenant_id, kind, day);\nCREATE INDEX IF NOT EXISTS plg_getting_started_events_tenant\n ON public.plg_getting_started_events (tenant_id, created_at DESC);\nALTER TABLE public.plg_getting_started_events ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_getting_started_events FORCE ROW LEVEL SECURITY;\nGRANT SELECT ON public.plg_getting_started_events TO authenticated;\nDROP POLICY IF EXISTS plg_getting_started_events_select ON public.plg_getting_started_events;\nCREATE POLICY plg_getting_started_events_select ON public.plg_getting_started_events\n FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n\n-- ── wizard keys this plugin owns (tech design, decision 5) ────────────────────\nSELECT app.seed_setting_keys($$[\n { \"key\": \"store.hours\", \"type\": \"object\", \"default\": {}, \"reader\": \"plugin:getting-started\",\n \"description\": \"Opening hours per weekday: {\\\\\"mon\\\\\":[{\\\\\"open\\\\\":\\\\\"11:00\\\\\",\\\\\"close\\\\\":\\\\\"23:00\\\\\"}],…}. Empty = not informed.\" },\n { \"key\": \"store.payment_methods\", \"type\": \"array\", \"default\": [], \"reader\": \"plugin:getting-started\",\n \"description\": \"Payment methods accepted at the counter and on delivery: cash, pix, credit, debit, voucher.\" },\n { \"key\": \"store.welcome_article\", \"type\": \"string\", \"default\": \"a\", \"reader\": \"plugin:getting-started\",\n \"description\": \"Article before the store name in greetings: a | o.\" }\n]$$::jsonb);\n\n-- ── the phase, derived (decision 1 and §3) ────────────────────────────────────\n-- security_invoker: the RLS of tenants decides which rows are visible (same pattern as v_team_members, db 004).\nCREATE OR REPLACE VIEW public.v_getting_started_journey WITH (security_invoker = true) AS\nSELECT t.id AS tenant_id,\n j.ready_at, j.trial_started_at, j.converted_at, j.wants_contract_at, j.source, j.utm,\n coalesce(j.trial_days, c.trial_days) AS trial_days,\n t.trial_ends_at,\n CASE WHEN j.trial_started_at IS NULL THEN t.trial_ends_at END AS setup_deadline_at,\n CASE\n WHEN j.converted_at IS NOT NULL THEN 'converted'\n WHEN t.trial_ends_at IS NOT NULL AND t.trial_ends_at <= now() THEN 'expired'\n WHEN j.trial_started_at IS NOT NULL THEN 'trial'\n ELSE 'setup'\n END AS phase\n FROM public.tenants t\n CROSS JOIN public.plg_getting_started_config c\n LEFT JOIN public.plg_getting_started_journeys j ON j.tenant_id = t.id;\nGRANT SELECT ON public.v_getting_started_journey TO authenticated;\n\n-- ── the minimums, one function (used by the start RPC and by the read) ────────\nCREATE OR REPLACE FUNCTION public.getting_started_minimums(p_tenant uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_menu boolean := false;\n v_hours boolean := false;\n v_order boolean := false;\n v_settings jsonb;\nBEGIN\n SELECT t.settings INTO v_settings FROM public.tenants t WHERE t.id = p_tenant;\n v_hours := jsonb_typeof(v_settings #> '{store,hours}') = 'object'\n AND (v_settings #> '{store,hours}') <> '{}'::jsonb;\n -- A pool without plugin-menu must not break the read.\n IF to_regclass('public.plg_menu_items') IS NOT NULL THEN\n EXECUTE $q$SELECT EXISTS (\n SELECT 1 FROM public.plg_menu_items mi\n JOIN public.products p ON p.id = mi.product_id\n WHERE mi.tenant_id = $1 AND mi.status = 'available' AND p.is_active\n AND coalesce(p.metadata->>'demo','') <> 'true')$q$\n INTO v_menu USING p_tenant;\n END IF;\n SELECT EXISTS (\n SELECT 1 FROM public.orders o\n WHERE o.tenant_id = p_tenant AND o.status = 'completed'\n AND o.metadata @> '{\"test\":true}'::jsonb\n ) INTO v_order;\n RETURN jsonb_build_object(\n 'menu', v_menu, 'hours', v_hours, 'test_order', v_order,\n 'ready', v_menu AND v_hours AND v_order\n );\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_minimums(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_minimums(uuid) TO authenticated, service_role;\n\n-- ── the read for the client ───────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_journey()\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_row record;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'getting_started_journey: no active tenant' USING ERRCODE = '42501';\n END IF;\n SELECT * INTO v_row FROM public.v_getting_started_journey v WHERE v.tenant_id = v_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('tenant_id', v_tenant, 'phase', 'setup',\n 'minimums', public.getting_started_minimums(v_tenant));\n END IF;\n RETURN to_jsonb(v_row) || jsonb_build_object('minimums', public.getting_started_minimums(v_tenant));\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_journey() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_journey() TO authenticated, service_role;\n`\n\nexport const MIGRATION_001_TRIAL_CLOCK = `-- 001_trial_clock.sql — start, end, reminders and the Faya lead. Depends on 000_journey.sql.\n\n-- ── start the trial ───────────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_start_trial(p_tenant uuid DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());\n v_days integer;\n v_min jsonb;\n v_started timestamptz;\n v_ends timestamptz;\nBEGIN\n -- Same gate as tenant_setting_set: a session, the tenant in scope, settings.manage.\n IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'getting_started_start_trial: no session or tenant out of scope' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('settings.manage') THEN\n RAISE EXCEPTION 'getting_started_start_trial: settings.manage is required' USING ERRCODE = '42501';\n END IF;\n\n -- Idempotent: a second call returns the state and leaves the date alone.\n SELECT j.trial_started_at INTO v_started FROM public.plg_getting_started_journeys j WHERE j.tenant_id = v_tenant;\n IF v_started IS NOT NULL THEN\n RETURN jsonb_build_object('ok', true, 'already_started', true, 'trial_started_at', v_started,\n 'trial_ends_at', (SELECT t.trial_ends_at FROM public.tenants t WHERE t.id = v_tenant));\n END IF;\n\n -- Minimums are checked here, not in the button.\n v_min := public.getting_started_minimums(v_tenant);\n IF NOT coalesce((v_min ->> 'ready')::boolean, false) THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'minimums_missing', 'minimums', v_min);\n END IF;\n\n SELECT c.trial_days INTO v_days FROM public.plg_getting_started_config c;\n v_days := coalesce(v_days, 14);\n v_started := now();\n v_ends := v_started + make_interval(days => v_days);\n\n INSERT INTO public.plg_getting_started_journeys (tenant_id, ready_at, trial_started_at, trial_days)\n VALUES (v_tenant, v_started, v_started, v_days)\n ON CONFLICT (tenant_id) DO UPDATE\n SET ready_at = coalesce(public.plg_getting_started_journeys.ready_at, EXCLUDED.ready_at),\n trial_started_at = EXCLUDED.trial_started_at,\n trial_days = EXCLUDED.trial_days,\n updated_at = now();\n\n -- The real clock. Passes tenants_trial_is_readonly because SECURITY DEFINER runs as the owner (postgres).\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = v_tenant;\n\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (v_tenant, 'trial_started', auth.uid(), jsonb_build_object('trial_days', v_days, 'ends_at', v_ends))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n\n PERFORM public.plg_emit_event('getting_started.trial_started',\n jsonb_build_object('trial_days', v_days, 'trial_ends_at', v_ends), 'tenant', v_tenant::text, v_tenant);\n\n RETURN jsonb_build_object('ok', true, 'trial_started_at', v_started, 'trial_ends_at', v_ends, 'trial_days', v_days);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;\n\n-- Agent executor shape (p_tenant_id, p_actor_user_id, p_payload), as agent_crm_create_lead.\nCREATE OR REPLACE FUNCTION public.agent_getting_started_start_trial(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n IF auth.uid() IS NOT NULL AND auth.uid() IS DISTINCT FROM p_actor_user_id THEN\n RAISE EXCEPTION 'agent_getting_started_start_trial: actor mismatch' USING ERRCODE = '42501';\n END IF;\n RETURN public.getting_started_start_trial(p_tenant_id);\nEND $$;\nREVOKE ALL ON FUNCTION public.agent_getting_started_start_trial(uuid, uuid, jsonb) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agent_getting_started_start_trial(uuid, uuid, jsonb) TO authenticated, service_role;\n\n-- ── \"I want to subscribe\" ─────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_request_contract(p_tenant uuid DEFAULT NULL, p_note text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());\n v_inserted boolean;\nBEGIN\n IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'getting_started_request_contract: no session or tenant out of scope' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('settings.manage') THEN\n RAISE EXCEPTION 'getting_started_request_contract: settings.manage is required' USING ERRCODE = '42501';\n END IF;\n\n -- Rate limit: the timeline's one-per-day lock. The V1 had none.\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (v_tenant, 'wants_contract', auth.uid(), jsonb_build_object('note', left(coalesce(p_note, ''), 500)))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING\n RETURNING true INTO v_inserted;\n IF NOT coalesce(v_inserted, false) THEN\n RETURN jsonb_build_object('ok', true, 'already_requested_today', true);\n END IF;\n\n INSERT INTO public.plg_getting_started_journeys (tenant_id, wants_contract_at)\n VALUES (v_tenant, now())\n ON CONFLICT (tenant_id) DO UPDATE\n SET wants_contract_at = coalesce(public.plg_getting_started_journeys.wants_contract_at, now()),\n updated_at = now();\n\n PERFORM public.plg_emit_event('getting_started.wants_contract',\n jsonb_build_object('note', left(coalesce(p_note, ''), 500)), 'tenant', v_tenant::text, v_tenant);\n\n RETURN jsonb_build_object('ok', true);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_request_contract(uuid, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_request_contract(uuid, text) TO authenticated, service_role;\n\n-- ── reminders D−3 / D0 ────────────────────────────────────────────────────────\n-- A tenant's calendar day. A bad zone name must not take the whole run down.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_local_date(p_at timestamptz, p_tz text)\nRETURNS date LANGUAGE plpgsql STABLE SET search_path TO '' AS $$\nBEGIN\n RETURN (p_at AT TIME ZONE coalesce(nullif(p_tz, ''), 'UTC'))::date;\nEXCEPTION WHEN OTHERS THEN\n RETURN (p_at AT TIME ZONE 'UTC')::date;\nEND $$;\n\n-- Who is due today. \\`p_now\\` is injectable so QA can simulate the day without touching the clock.\n-- Due-ness is by the tenant's local day; the once-a-day lock is by the run's UTC day, so a\n-- reminder sent before an extend does not block the one the new end date earns.\nCREATE OR REPLACE FUNCTION public.getting_started_reminders_due(p_now timestamptz DEFAULT now())\nRETURNS TABLE (tenant_id uuid, kind text, trial_ends_at timestamptz)\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$\n WITH due AS (\n SELECT j.tenant_id, t.trial_ends_at,\n public.plg_getting_started_local_date(t.trial_ends_at, t.settings ->> 'timezone') AS end_day,\n public.plg_getting_started_local_date(p_now, t.settings ->> 'timezone') AS today\n FROM public.plg_getting_started_journeys j\n JOIN public.tenants t ON t.id = j.tenant_id\n WHERE j.trial_started_at IS NOT NULL\n AND j.converted_at IS NULL\n AND t.trial_ends_at IS NOT NULL\n )\n SELECT d.tenant_id,\n CASE WHEN d.end_day = d.today + 3 THEN 'reminder_d3' ELSE 'reminder_d0' END AS kind,\n d.trial_ends_at\n FROM due d\n WHERE d.end_day IN (d.today + 3, d.today)\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = d.tenant_id\n AND e.day = (p_now AT TIME ZONE 'UTC')::date\n AND e.kind = CASE WHEN d.end_day = d.today + 3 THEN 'reminder_d3' ELSE 'reminder_d0' END\n );\n$$;\nREVOKE ALL ON FUNCTION public.getting_started_reminders_due(timestamptz) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.getting_started_reminders_due(timestamptz) TO service_role;\n\n-- Templates are per tenant (notifications UNIQUE (tenant_id, key, channel, locale)).\nCREATE OR REPLACE FUNCTION public.getting_started_ensure_templates(p_tenant uuid)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n IF to_regclass('public.plg_notifications_templates') IS NULL THEN RETURN; END IF;\n INSERT INTO public.plg_notifications_templates (tenant_id, key, channel, locale, subject, body, variables, is_default)\n VALUES\n (p_tenant, 'getting_started.reminder_d3', 'in_app', 'pt-BR', 'Seu teste termina em 3 dias',\n 'Faltam 3 dias de teste grátis. Até lá está tudo liberado. Para continuar depois, é só escolher um plano em Configurações › Assinatura — sem fidelidade.',\n '[\"trial_ends_at\"]'::jsonb, true),\n (p_tenant, 'getting_started.reminder_d0', 'in_app', 'pt-BR', 'Hoje é o último dia do seu teste',\n 'Termina hoje. Depois disso a loja continua e seus dados também; o que é do plano pago fica fechado até você assinar.',\n '[\"trial_ends_at\"]'::jsonb, true)\n ON CONFLICT (tenant_id, key, channel, locale) DO NOTHING;\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_ensure_templates(uuid) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.getting_started_ensure_templates(uuid) TO service_role;\n\n-- The job. Writes the event BEFORE enqueueing (the lock), then enqueues through notifications when present.\nCREATE OR REPLACE FUNCTION public.getting_started_reminders_run(p_now timestamptz DEFAULT now())\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n r record;\n v_day date := (p_now AT TIME ZONE 'UTC')::date;\n v_person uuid;\n v_sent integer := 0;\n v_skipped integer := 0;\n v_queue uuid;\n v_enqueue boolean := to_regprocedure('public.notifications_enqueue(uuid,text,uuid,jsonb,text,timestamptz,text,text,uuid,uuid)') IS NOT NULL;\nBEGIN\n IF NOT pg_try_advisory_xact_lock(hashtext('getting_started_reminders')) THEN\n RETURN jsonb_build_object('skipped', 'another run is active');\n END IF;\n\n FOR r IN SELECT * FROM public.getting_started_reminders_due(p_now) LOOP\n -- The owner: the team person whose e-mail is the first active member's.\n SELECT pe.id INTO v_person\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = r.tenant_id AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, day, channel, payload)\n VALUES (r.tenant_id, r.kind, v_day, CASE WHEN v_person IS NULL THEN 'none' ELSE 'in_app' END,\n jsonb_build_object('trial_ends_at', r.trial_ends_at))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n IF NOT FOUND THEN v_skipped := v_skipped + 1; CONTINUE; END IF;\n\n IF v_person IS NULL OR NOT v_enqueue THEN\n v_skipped := v_skipped + 1; CONTINUE;\n END IF;\n\n PERFORM public.getting_started_ensure_templates(r.tenant_id);\n BEGIN\n EXECUTE 'SELECT public.notifications_enqueue($1, $2, $3, $4, $5, $6, $7)'\n INTO v_queue\n USING r.tenant_id, 'getting_started.' || r.kind, v_person,\n jsonb_build_object('trial_ends_at', r.trial_ends_at),\n 'getting_started:' || r.kind || ':' || r.tenant_id || ':' || v_day, p_now, 'in_app';\n v_sent := v_sent + 1;\n EXCEPTION WHEN OTHERS THEN\n -- A provider down for one tenant does not stop the others; the event already marked the day.\n UPDATE public.plg_getting_started_events SET payload = payload || jsonb_build_object('error', SQLERRM)\n WHERE tenant_id = r.tenant_id AND kind = r.kind AND day = v_day;\n v_skipped := v_skipped + 1;\n END;\n END LOOP;\n\n RETURN jsonb_build_object('sent', v_sent, 'skipped', v_skipped);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_reminders_run(timestamptz) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.getting_started_reminders_run(timestamptz) TO service_role;\n\n-- cron: the spine's guard, plus a row in app.scheduled_jobs so the schedule is visible without pg_cron.\nDO $cron$\nDECLARE v_hour integer;\nBEGIN\n SELECT reminder_hour_utc INTO v_hour FROM public.plg_getting_started_config;\n v_hour := coalesce(v_hour, 13);\n INSERT INTO app.scheduled_jobs (job_name, schedule, command, scheduled, reason)\n VALUES ('fayz_getting_started_reminders', format('0 %s * * *', v_hour), 'SELECT public.getting_started_reminders_run()', false, 'pending')\n ON CONFLICT (job_name) DO UPDATE\n SET schedule = EXCLUDED.schedule, command = EXCLUDED.command, updated_at = now();\n IF to_regprocedure('cron.schedule(text, text, text)') IS NULL THEN\n UPDATE app.scheduled_jobs SET scheduled = false, reason = 'pg_cron absent' WHERE job_name = 'fayz_getting_started_reminders';\n RAISE NOTICE 'getting-started: pg_cron absent — reminders not scheduled';\n RETURN;\n END IF;\n PERFORM cron.unschedule(jobid) FROM cron.job WHERE jobname = 'fayz_getting_started_reminders';\n PERFORM cron.schedule('fayz_getting_started_reminders', format('0 %s * * *', v_hour), $job$SELECT public.getting_started_reminders_run()$job$);\n UPDATE app.scheduled_jobs SET scheduled = true, reason = NULL WHERE job_name = 'fayz_getting_started_reminders';\nEND $cron$;\n\n-- ── the lead in Faya's CRM ────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug INTO v_slug FROM public.plg_getting_started_config c;\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(rpad(btrim(v_name), 2, '.'), 120), v_phone, v_email,\n 'getting-started', 'Teste grátis — ' || coalesce(v_slug, ''),\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- Handlers in the shape consume_event_log runs: public.<handler>(jsonb), where the argument is\n-- to_jsonb(event_log row) — so \\`tenant_id\\` and \\`payload\\` are top-level keys.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$\n SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\n$$;\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_wants_contract(p_event jsonb)\nRETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$\n SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'quer_contratar', coalesce(p_event -> 'payload', '{}'::jsonb));\n$$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) TO service_role;\n\nSELECT public.register_event_subscription('getting-started', 'getting_started.trial_started', 'plg_getting_started_on_trial_started', 'Faya lead when the trial starts', 5);\nSELECT public.register_event_subscription('getting-started', 'getting_started.wants_contract', 'plg_getting_started_on_wants_contract', 'Faya lead when the tenant wants to subscribe', 5);\n`\n\nexport const MIGRATION_002_PLATFORM_OPERATOR = `-- 002_platform_operator.sql — the platform reads every tenant's trial and acts on it.\n-- Functions only; no new table. Depends on 000_journey.sql and 001_trial_clock.sql.\n--\n-- \"Platform operator\" = an active member of the tenant named by\n-- plg_getting_started_config.crm_tenant_slug, acting INSIDE that tenant\n-- (app.current_tenant_id()), or — where the live cluster has the platform\n-- schema — platform.can(auth.uid(), 'tenants.write'). Same shape as\n-- support_is_operator(): the boundary is a SECURITY DEFINER function with a\n-- guard; no RLS policy opens between tenants.\n\n-- ── who is the platform ───────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_platform_tenant()\nRETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$\n SELECT t.id\n FROM public.plg_getting_started_config c\n JOIN public.tenants t ON t.slug = c.crm_tenant_slug\n LIMIT 1;\n$$;\nREVOKE ALL ON FUNCTION public.getting_started_platform_tenant() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_platform_tenant() TO authenticated, service_role;\n\nCREATE OR REPLACE FUNCTION public.getting_started_is_platform_operator()\nRETURNS boolean LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_uid uuid := auth.uid();\n v_platform uuid := public.getting_started_platform_tenant();\n v_can boolean := false;\nBEGIN\n IF v_uid IS NULL THEN RETURN false; END IF;\n IF v_platform IS NOT NULL AND v_platform = app.current_tenant_id() AND EXISTS (\n SELECT 1 FROM app.memberships m\n WHERE m.tenant_id = v_platform AND m.user_id = v_uid AND m.active) THEN\n RETURN true;\n END IF;\n -- Optional reinforcement: the platform schema lives only on the live cluster.\n IF to_regprocedure('platform.can(uuid,text)') IS NOT NULL THEN\n EXECUTE 'SELECT platform.can($1, $2)' INTO v_can USING v_uid, 'tenants.write';\n END IF;\n RETURN coalesce(v_can, false);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_is_platform_operator() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_is_platform_operator() TO authenticated, service_role;\n\nCREATE OR REPLACE FUNCTION public.getting_started_platform_guard()\nRETURNS void LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n IF NOT public.getting_started_is_platform_operator() THEN\n RAISE EXCEPTION 'not a platform operator' USING ERRCODE = '42501';\n END IF;\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_platform_guard() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_platform_guard() TO authenticated, service_role;\n\n-- ── timeline note that survives a repeat on the same day ──────────────────────\n-- plg_getting_started_events is UNIQUE (tenant_id, kind, day). A second\n-- extension on the same day keeps one row and folds the latest payload in,\n-- counting the repeats; the customer-success timeline keeps every action.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_note(p_tenant uuid, p_kind text, p_payload jsonb, p_actor uuid DEFAULT auth.uid())\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (p_tenant, p_kind, p_actor, coalesce(p_payload, '{}'::jsonb))\n ON CONFLICT (tenant_id, kind, day) DO UPDATE\n SET payload = public.plg_getting_started_events.payload || EXCLUDED.payload\n || jsonb_build_object('repeats', coalesce((public.plg_getting_started_events.payload ->> 'repeats')::int, 0) + 1),\n actor_id = coalesce(EXCLUDED.actor_id, public.plg_getting_started_events.actor_id);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_note(uuid, text, jsonb, uuid) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_note(uuid, text, jsonb, uuid) TO service_role;\n\n-- ── every tenant's journey, for the platform ──────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_platform_journeys(p_search text DEFAULT NULL, p_phase text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_platform uuid := public.getting_started_platform_tenant();\n v_search text := nullif(btrim(coalesce(p_search, '')), '');\n v_result jsonb;\nBEGIN\n PERFORM public.getting_started_platform_guard();\n SELECT coalesce(jsonb_agg(to_jsonb(r) ORDER BY r.tenant_created_at DESC), '[]'::jsonb) INTO v_result\n FROM (\n SELECT t.id AS tenant_id,\n t.name AS tenant_name,\n t.slug AS tenant_slug,\n t.vertical_id,\n t.plan AS pool_plan,\n t.settings #>> '{billing,plan_id}' AS grants_plan,\n t.kind,\n t.created_at AS tenant_created_at,\n coalesce((SELECT array_agg(ta.app_id ORDER BY ta.created_at)\n FROM app.tenant_apps ta WHERE ta.tenant_id = t.id AND ta.status = 'active'), '{}'::text[]) AS apps,\n coalesce((SELECT l.state FROM app.tenant_lifecycle l WHERE l.tenant_id = t.id), 'active') AS lifecycle_state,\n coalesce(j.phase, 'setup') AS phase,\n j.ready_at, j.trial_started_at, j.trial_ends_at, j.setup_deadline_at, j.converted_at, j.wants_contract_at,\n coalesce(j.trial_days, c.trial_days) AS trial_days,\n j.source, coalesce(j.utm, '{}'::jsonb) AS utm,\n public.getting_started_minimums(t.id) AS minimums,\n (SELECT jsonb_build_object('email', u.email, 'name', pe.name, 'phone', pe.phone)\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.id = m.person_id\n WHERE m.tenant_id = t.id AND m.active\n ORDER BY m.created_at LIMIT 1) AS contact,\n (SELECT jsonb_build_object('kind', e.kind, 'created_at', e.created_at)\n FROM public.plg_getting_started_events e\n WHERE e.tenant_id = t.id ORDER BY e.created_at DESC LIMIT 1) AS last_event,\n (SELECT count(*) FROM public.plg_getting_started_events e WHERE e.tenant_id = t.id) AS events_count\n FROM public.tenants t\n CROSS JOIN public.plg_getting_started_config c\n LEFT JOIN public.v_getting_started_journey j ON j.tenant_id = t.id\n WHERE (v_platform IS NULL OR t.id <> v_platform)\n AND (p_phase IS NULL OR coalesce(j.phase, 'setup') = p_phase)\n AND (v_search IS NULL\n OR t.name ILIKE '%' || v_search || '%'\n OR t.slug ILIKE '%' || v_search || '%'\n OR EXISTS (SELECT 1 FROM app.memberships m JOIN auth.users u ON u.id = m.user_id\n WHERE m.tenant_id = t.id AND m.active AND u.email ILIKE '%' || v_search || '%'))\n ) r;\n RETURN v_result;\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_platform_journeys(text, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_platform_journeys(text, text) TO authenticated, service_role;\n\n-- ── act on one tenant's trial ─────────────────────────────────────────────────\n-- extend : adds p_days (1..90) on top of greatest(trial_ends_at, now()) — V1 §6.6.\n-- end_now : trial_ends_at = now(); emits getting_started.expired.\n-- convert : converted_at = coalesce(converted_at, now()); emits getting_started.converted.\n-- remind : one in_app notice to the first member, template getting_started.reminder_d0.\n-- The operator is not a member of the house, and plg_emit_event refuses a caller outside\n-- the tenant. The guard above already authorised the platform; file the fact directly.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_platform_emit(p_tenant uuid, p_key text, p_payload jsonb)\nRETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE v_id uuid;\nBEGIN\n INSERT INTO public.event_log (tenant_id, event_key, payload, actor_id, subject_type, subject_id)\n VALUES (p_tenant, p_key, coalesce(p_payload, '{}'::jsonb), auth.uid(), 'tenant', p_tenant::text)\n RETURNING id INTO v_id;\n RETURN v_id;\nEXCEPTION WHEN OTHERS THEN\n RAISE WARNING '[getting_started_trial_set] % not recorded: %', p_key, SQLERRM;\n RETURN NULL;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_platform_emit(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_platform_emit(uuid, text, jsonb) TO service_role;\n\nCREATE OR REPLACE FUNCTION public.getting_started_trial_set(p_tenant uuid, p_action text, p_days integer DEFAULT NULL, p_reason text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_platform uuid := public.getting_started_platform_tenant();\n v_actor uuid := auth.uid();\n v_reason text := nullif(btrim(coalesce(p_reason, '')), '');\n v_t record;\n v_j record;\n v_before jsonb;\n v_after jsonb;\n v_payload jsonb := '{}'::jsonb;\n v_ends timestamptz;\n v_converted timestamptz;\n v_person uuid;\n v_nonce text;\n v_queue uuid;\n v_enqueue boolean := to_regprocedure('public.notifications_enqueue(uuid,text,uuid,jsonb,text,timestamptz,text,text,uuid,uuid)') IS NOT NULL;\n v_ok boolean := true;\nBEGIN\n PERFORM public.getting_started_platform_guard();\n IF p_action IS NULL OR p_action NOT IN ('extend', 'end_now', 'convert', 'remind') THEN\n RAISE EXCEPTION 'invalid action %', p_action USING ERRCODE = '22023';\n END IF;\n IF p_tenant IS NULL OR p_tenant = v_platform THEN\n RAISE EXCEPTION 'tenant out of scope' USING ERRCODE = '22023';\n END IF;\n SELECT t.id, t.trial_ends_at INTO v_t FROM public.tenants t WHERE t.id = p_tenant FOR UPDATE;\n IF NOT FOUND THEN RAISE EXCEPTION 'tenant not found' USING ERRCODE = 'P0002'; END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j FROM public.plg_getting_started_journeys j WHERE j.tenant_id = p_tenant;\n\n v_before := jsonb_build_object('trial_ends_at', v_t.trial_ends_at, 'trial_started_at', v_j.trial_started_at, 'converted_at', v_j.converted_at);\n v_ends := v_t.trial_ends_at;\n v_converted := v_j.converted_at;\n\n IF p_action = 'extend' THEN\n IF p_days IS NULL OR p_days NOT BETWEEN 1 AND 90 THEN\n RAISE EXCEPTION 'p_days must be between 1 and 90' USING ERRCODE = '22023';\n END IF;\n v_ends := greatest(coalesce(v_t.trial_ends_at, now()), now()) + make_interval(days => p_days);\n INSERT INTO public.plg_getting_started_journeys (tenant_id, trial_started_at, trial_days)\n VALUES (p_tenant, now(), (SELECT c.trial_days FROM public.plg_getting_started_config c))\n ON CONFLICT (tenant_id) DO UPDATE\n SET trial_started_at = coalesce(public.plg_getting_started_journeys.trial_started_at, EXCLUDED.trial_started_at),\n updated_at = now();\n -- Passes tenants_trial_is_readonly: SECURITY DEFINER runs as the owner.\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = p_tenant;\n v_payload := jsonb_build_object('days', p_days, 'ends_at', v_ends, 'reason', v_reason);\n PERFORM public.plg_getting_started_note(p_tenant, 'trial_extended', v_payload, v_actor);\n\n ELSIF p_action = 'end_now' THEN\n IF v_reason IS NULL THEN RAISE EXCEPTION 'a reason is required to end a trial' USING ERRCODE = '22023'; END IF;\n v_ends := now();\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = p_tenant;\n v_payload := jsonb_build_object('ends_at', v_ends, 'reason', v_reason);\n PERFORM public.plg_getting_started_note(p_tenant, 'trial_ended', v_payload, v_actor);\n PERFORM public.plg_getting_started_platform_emit(p_tenant, 'getting_started.expired',\n jsonb_build_object('trial_ends_at', v_ends, 'reason', v_reason, 'ended_by', v_actor));\n\n ELSIF p_action = 'convert' THEN\n -- Without a proposal the operator writes why; with one, the caller passes 'proposal:<number>'.\n IF v_reason IS NULL THEN RAISE EXCEPTION 'a reason is required to convert without a proposal' USING ERRCODE = '22023'; END IF;\n v_converted := coalesce(v_j.converted_at, now());\n INSERT INTO public.plg_getting_started_journeys (tenant_id, converted_at)\n VALUES (p_tenant, v_converted)\n ON CONFLICT (tenant_id) DO UPDATE\n SET converted_at = coalesce(public.plg_getting_started_journeys.converted_at, EXCLUDED.converted_at),\n updated_at = now();\n v_payload := jsonb_build_object('converted_at', v_converted, 'reason', v_reason);\n IF v_j.converted_at IS NULL THEN\n PERFORM public.plg_getting_started_note(p_tenant, 'converted', v_payload, v_actor);\n PERFORM public.plg_getting_started_platform_emit(p_tenant, 'getting_started.converted',\n jsonb_build_object('converted_at', v_converted, 'reason', v_reason, 'converted_by', v_actor));\n END IF;\n\n ELSIF p_action = 'remind' THEN\n SELECT pe.id INTO v_person\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at LIMIT 1;\n v_nonce := left(md5(random()::text || clock_timestamp()::text), 12);\n v_payload := jsonb_build_object('trial_ends_at', v_t.trial_ends_at, 'nonce', v_nonce, 'reason', v_reason,\n 'channel', CASE WHEN v_person IS NULL OR NOT v_enqueue THEN 'none' ELSE 'in_app' END);\n IF v_person IS NULL OR NOT v_enqueue THEN\n v_ok := false;\n v_payload := v_payload || jsonb_build_object('skipped', CASE WHEN v_person IS NULL THEN 'no contact' ELSE 'plugin-notifications absent' END);\n ELSE\n PERFORM public.getting_started_ensure_templates(p_tenant);\n EXECUTE 'SELECT public.notifications_enqueue($1, $2, $3, $4, $5, $6, $7)'\n INTO v_queue\n USING p_tenant, 'getting_started.reminder_d0', v_person,\n jsonb_build_object('trial_ends_at', v_t.trial_ends_at),\n 'getting_started:reminder_manual:' || p_tenant || ':' || v_nonce, now(), 'in_app';\n v_payload := v_payload || jsonb_build_object('queue_id', v_queue);\n END IF;\n PERFORM public.plg_getting_started_note(p_tenant, 'reminder_manual', v_payload, v_actor);\n END IF;\n\n v_after := jsonb_build_object('trial_ends_at', v_ends, 'converted_at', v_converted);\n -- audit_log derives the tenant from the session for a signed-in caller; the\n -- customer tenant travels in the metadata. A service_role run names the platform.\n PERFORM public.audit_log('getting_started.' || p_action, 'tenant', p_tenant, v_reason, v_before, v_after, NULL,\n v_payload || jsonb_build_object('customer_tenant_id', p_tenant),\n CASE WHEN v_actor IS NULL THEN v_platform ELSE NULL END);\n\n RETURN jsonb_build_object('ok', v_ok, 'action', p_action, 'tenant_id', p_tenant,\n 'trial_ends_at', v_ends, 'converted_at', v_converted);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_trial_set(uuid, text, integer, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_trial_set(uuid, text, integer, text) TO authenticated, service_role;\n`\n\nexport const MIGRATION_003_THE_CLOCK_STARTS_ONCE_AND_ONLY_FOR_WHO_MAY = `-- 003_the_clock_starts_once_and_only_for_who_may.sql — the gates the audit found open.\n-- Functions and grants only; no new table. Depends on 000, 001 and 002.\n--\n-- * getting_started_start_trial: the tenant row is locked (FOR UPDATE), so two\n-- clicks cannot start twice and emit twice; a tenant with no trial window\n-- (trial_ends_at IS NULL — legacy or paying, db 044), an already converted\n-- journey or a paid plan is refused instead of being handed a clock that\n-- expires on them.\n-- * getting_started_minimums: a member of the tenant (or the platform\n-- operator of 002, or service_role) — not any signed-in user probing other\n-- tenants by id.\n-- * plg_getting_started_config: the client reads only the columns it needs;\n-- crm_tenant_slug is the platform's business.\n-- * the event handlers accept only what the RPCs wrote — plg_emit_event is\n-- open to any member, and a hand-made \\`trial_started\\` must not create a lead.\n\n-- ── the minimums: only for who may ask ───────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_minimums(p_tenant uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_menu boolean := false;\n v_hours boolean := false;\n v_order boolean := false;\n v_settings jsonb;\n v_operator boolean := false;\nBEGIN\n IF p_tenant IS NULL THEN\n RAISE EXCEPTION 'getting_started_minimums: tenant is required' USING ERRCODE = '22023';\n END IF;\n -- service_role (jobs), a member of the tenant, or the platform operator (002).\n IF auth.role() IS DISTINCT FROM 'service_role' AND NOT app.is_member(p_tenant) THEN\n IF to_regprocedure('public.getting_started_is_platform_operator()') IS NOT NULL THEN\n EXECUTE 'SELECT public.getting_started_is_platform_operator()' INTO v_operator;\n END IF;\n IF NOT coalesce(v_operator, false) THEN\n RAISE EXCEPTION 'getting_started_minimums: not a member of this tenant' USING ERRCODE = '42501';\n END IF;\n END IF;\n\n SELECT t.settings INTO v_settings FROM public.tenants t WHERE t.id = p_tenant;\n v_hours := jsonb_typeof(v_settings #> '{store,hours}') = 'object'\n AND (v_settings #> '{store,hours}') <> '{}'::jsonb;\n -- A pool without plugin-menu must not break the read.\n IF to_regclass('public.plg_menu_items') IS NOT NULL THEN\n EXECUTE $q$SELECT EXISTS (\n SELECT 1 FROM public.plg_menu_items mi\n JOIN public.products p ON p.id = mi.product_id\n WHERE mi.tenant_id = $1 AND mi.status = 'available' AND p.is_active\n AND coalesce(p.metadata->>'demo','') <> 'true')$q$\n INTO v_menu USING p_tenant;\n END IF;\n SELECT EXISTS (\n SELECT 1 FROM public.orders o\n WHERE o.tenant_id = p_tenant AND o.status = 'completed'\n AND o.metadata @> '{\"test\":true}'::jsonb\n ) INTO v_order;\n RETURN jsonb_build_object(\n 'menu', v_menu, 'hours', v_hours, 'test_order', v_order,\n 'ready', v_menu AND v_hours AND v_order\n );\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_minimums(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_minimums(uuid) TO authenticated, service_role;\n\n-- ── start the trial: once, locked, and only for a tenant that can be on trial ─\nCREATE OR REPLACE FUNCTION public.getting_started_start_trial(p_tenant uuid DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());\n v_days integer;\n v_min jsonb;\n v_t record;\n v_j record;\n v_started timestamptz;\n v_ends timestamptz;\nBEGIN\n -- Same gate as tenant_setting_set: a session, the tenant in scope, settings.manage.\n IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'getting_started_start_trial: no session or tenant out of scope' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('settings.manage') THEN\n RAISE EXCEPTION 'getting_started_start_trial: settings.manage is required' USING ERRCODE = '42501';\n END IF;\n\n -- The lock: a second concurrent call waits here and then sees the start.\n SELECT t.id, t.trial_ends_at, t.plan INTO v_t FROM public.tenants t WHERE t.id = v_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'getting_started_start_trial: tenant not found' USING ERRCODE = 'P0002';\n END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j\n FROM public.plg_getting_started_journeys j WHERE j.tenant_id = v_tenant FOR UPDATE;\n\n -- Idempotent: a second call returns the state and leaves the date alone.\n IF v_j.trial_started_at IS NOT NULL THEN\n RETURN jsonb_build_object('ok', true, 'already_started', true, 'trial_started_at', v_j.trial_started_at,\n 'trial_ends_at', v_t.trial_ends_at);\n END IF;\n\n v_min := public.getting_started_minimums(v_tenant);\n\n -- Who is already converted, or already paying, is not offered a clock that\n -- would expire on them. \\`trial_ends_at IS NULL\\` is the legacy/paying tenant\n -- (db 044): no window, no trial.\n IF v_j.converted_at IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'already_converted', 'minimums', v_min);\n END IF;\n IF coalesce(nullif(btrim(v_t.plan), ''), 'free') <> 'free' THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'already_paid', 'minimums', v_min, 'plan', v_t.plan);\n END IF;\n IF v_t.trial_ends_at IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'no_trial_window', 'minimums', v_min);\n END IF;\n\n -- Minimums are checked here, not in the button.\n IF NOT coalesce((v_min ->> 'ready')::boolean, false) THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'minimums_missing', 'minimums', v_min);\n END IF;\n\n SELECT c.trial_days INTO v_days FROM public.plg_getting_started_config c;\n v_days := coalesce(v_days, 14);\n v_started := now();\n v_ends := v_started + make_interval(days => v_days);\n\n INSERT INTO public.plg_getting_started_journeys (tenant_id, ready_at, trial_started_at, trial_days)\n VALUES (v_tenant, v_started, v_started, v_days)\n ON CONFLICT (tenant_id) DO UPDATE\n SET ready_at = coalesce(public.plg_getting_started_journeys.ready_at, EXCLUDED.ready_at),\n trial_started_at = EXCLUDED.trial_started_at,\n trial_days = EXCLUDED.trial_days,\n updated_at = now();\n\n -- The real clock. Passes tenants_trial_is_readonly (db 221): this DEFINER\n -- function runs as its owner, which the gate lets through.\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = v_tenant;\n\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (v_tenant, 'trial_started', auth.uid(), jsonb_build_object('trial_days', v_days, 'ends_at', v_ends))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n\n PERFORM public.plg_emit_event('getting_started.trial_started',\n jsonb_build_object('trial_days', v_days, 'trial_ends_at', v_ends), 'tenant', v_tenant::text, v_tenant);\n\n RETURN jsonb_build_object('ok', true, 'trial_started_at', v_started, 'trial_ends_at', v_ends, 'trial_days', v_days);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;\n\n-- ── the platform config: the client reads only what it needs ─────────────────\n-- The view (000) reads trial_days; the setup window is documentation of the\n-- spine default. crm_tenant_slug and reminder_hour_utc are read inside the\n-- DEFINER functions only. The policy stays (USING true); the GRANT narrows.\nREVOKE SELECT ON public.plg_getting_started_config FROM authenticated;\nGRANT SELECT (id, trial_days, setup_window_days) ON public.plg_getting_started_config TO authenticated;\n\n-- ── the handlers accept only what the RPCs wrote ──────────────────────────────\n-- plg_emit_event is open to any member of the tenant; the journey row and the\n-- timeline are written by DEFINER RPCs only (no write policy). So a lead is\n-- created only when the mark the RPC leaves is there.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.trial_started_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'trial_started') THEN\n RETURN jsonb_build_object('skipped', 'trial_started was not written by getting_started_start_trial');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_wants_contract(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.wants_contract_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'wants_contract'\n AND e.created_at >= now() - interval '2 days') THEN\n RETURN jsonb_build_object('skipped', 'wants_contract was not written by getting_started_request_contract');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'quer_contratar', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) TO service_role;\n`\n\nexport const MIGRATION_004_LEAD_KEEPS_THE_NAME = `-- 004_lead_keeps_the_name.sql — 001 padded the house name with rpad(name, 2), which also CUTS it to 2\n-- characters: every lead in the Faya CRM was born named \"Bu\". Pad only names shorter than 2.\n\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug INTO v_slug FROM public.plg_getting_started_config c;\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(CASE WHEN length(btrim(v_name)) < 2 THEN rpad(btrim(v_name), 2, '.') ELSE btrim(v_name) END, 120), v_phone, v_email,\n 'getting-started', 'Teste grátis — ' || coalesce(v_slug, ''),\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- Handlers in the shape consume_event_log runs: public.<handler>(jsonb), where the argument is\n-- to_jsonb(event_log row) — so \\`tenant_id\\` and \\`payload\\` are top-level keys.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$\n SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\n$$;\n\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n`\n\nexport const MIGRATION_005_QA_LEADS_GO_TO_QA_PLATFORM = `-- 005_qa_leads_go_to_qa_platform.sql — a QA signup never lands in the real Faya funnel.\n--\n-- The V2-native e2e (qa+e2e-<ts>@fayalabs.com) created leads in the REAL \\`controlgroup\\` CRM because\n-- plg_getting_started_lead always targeted crm_tenant_slug. Now, when the tenant's owner/contact\n-- e-mail matches qa_email_pattern (ILIKE), the lead goes to qa_crm_tenant_slug instead. When that\n-- tenant does not exist the lead is NOT created and the handler result (event_deliveries.result)\n-- says why — it never falls back to the real CRM. Any other e-mail: unchanged (004 behaviour).\n--\n-- Also restores 003's guarded plg_getting_started_on_trial_started: 004 re-created the unguarded 001\n-- form by accident (any tenant member could emit trial_started and mint a lead without a trial).\n\n-- ── config: where QA leads go ─────────────────────────────────────────────────\nALTER TABLE public.plg_getting_started_config\n ADD COLUMN IF NOT EXISTS qa_email_pattern text NOT NULL DEFAULT 'qa+%@fayalabs.com',\n ADD COLUMN IF NOT EXISTS qa_crm_tenant_slug text NOT NULL DEFAULT 'controlgroup-qa';\n-- 003 narrowed authenticated to (id, trial_days, setup_window_days); the new columns are read by\n-- DEFINER functions only, so no column grant is added here.\n\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_crm_slug text;\n v_qa_pat text;\n v_qa_slug text;\n v_qa boolean := false;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug, nullif(btrim(c.qa_email_pattern), ''), nullif(btrim(c.qa_crm_tenant_slug), '')\n INTO v_crm_slug, v_qa_pat, v_qa_slug\n FROM public.plg_getting_started_config c;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n\n -- QA contact → the QA platform's CRM, never the real one (not even as a fallback).\n v_qa := v_qa_pat IS NOT NULL AND v_email IS NOT NULL AND v_email ILIKE v_qa_pat;\n IF v_qa THEN\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_qa_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN\n RETURN jsonb_build_object('skipped', 'qa crm tenant not found', 'qa', true,\n 'qa_crm_tenant_slug', v_qa_slug, 'tag', p_tag);\n END IF;\n ELSE\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_crm_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n END IF;\n\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(CASE WHEN length(btrim(v_name)) < 2 THEN rpad(btrim(v_name), 2, '.') ELSE btrim(v_name) END, 120), v_phone, v_email,\n 'getting-started', 'Teste grátis — ' || coalesce(v_slug, ''),\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag)\n || CASE WHEN v_qa THEN jsonb_build_object('qa', true, 'crm_tenant_slug', v_qa_slug) ELSE '{}'::jsonb END;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- ── 003's guard, back (004 regressed it to the 001 form) ──────────────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.trial_started_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'trial_started') THEN\n RETURN jsonb_build_object('skipped', 'trial_started was not written by getting_started_start_trial');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;\n`\n\nexport const MIGRATION_006_THE_LEAD_IS_BORN_AT_SIGNUP = `-- 006_the_lead_is_born_at_signup.sql — the trial request becomes a lead when the workspace is born,\n-- not only when \"Iniciar teste\" is clicked (TP-08).\n--\n-- Before: a V2 house reached the Faya CRM only through getting_started.trial_started (005). A house\n-- that signed up and never started the trial was invisible to sales.\n--\n-- Now:\n-- * When the getting-started binding becomes active (app.tenant_plugins: create_tenant through the\n-- food vertical defaults, or ensureDefaultModules / tenant_plugin_set right after), a DEFINER\n-- trigger leaves its mark on the timeline (kind 'signed_up', once per tenant, ever) and files\n-- getting_started.signed_up. The trigger runs inside the signup transaction and never fails it.\n-- It does not build the lead there: the owner's membership may not exist yet (the binding is\n-- written by the tenants AFTER INSERT trigger, before create_tenant inserts the membership).\n-- * The handler (async, after commit) accepts only what the trigger wrote — same rule as 003:\n-- plg_emit_event is open to any member, and a hand-made signed_up must not mint a lead — and\n-- creates the lead with origem \"Cadastro V2\". create_public_lead puts it on the first open\n-- stage of the CRM's default funnel (crm_lead_to_board).\n-- * When the trial starts, plg_getting_started_lead finds the SAME lead (create_public_lead dedupes\n-- by the owner's e-mail) and moves its deal FORWARD to the stage named by\n-- config.lead_stage_patterns->>'trial_started' (ILIKE, default '%teste%') — never backwards,\n-- never out of won/lost. No matching stage: nothing moves and the result says why.\n-- * QA rule of 005 kept: qa+…@fayalabs.com → controlgroup-qa, never controlgroup.\n-- * 003's guarded plg_getting_started_on_trial_started is NOT re-created here (004 lost it once by\n-- re-creating it; 005 restored it). This file only touches the lead function it calls.\n--\n-- Only who is signing up gets a lead: free plan, inside the trial window, not converted, trial not\n-- started, workspace created in the last 48 hours. A binding switched on for an old or paying\n-- tenant (an operator, a backfill) is not a signup.\n\n-- ── config: where each journey tag moves the deal ─────────────────────────────\nALTER TABLE public.plg_getting_started_config\n ADD COLUMN IF NOT EXISTS lead_stage_patterns jsonb NOT NULL DEFAULT '{\"trial_started\": \"%teste%\"}'::jsonb;\n-- Read by DEFINER functions only (003 narrowed the column grant of authenticated).\n\n-- ── the lead: origem per tag, and the stage it moves to ───────────────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_crm_slug text;\n v_qa_pat text;\n v_qa_slug text;\n v_qa boolean := false;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\n v_patterns jsonb;\n v_pattern text;\n v_origin text;\n v_deal record;\n v_target record;\n v_stage jsonb;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug, nullif(btrim(c.qa_email_pattern), ''), nullif(btrim(c.qa_crm_tenant_slug), ''),\n coalesce(c.lead_stage_patterns, '{}'::jsonb)\n INTO v_crm_slug, v_qa_pat, v_qa_slug, v_patterns\n FROM public.plg_getting_started_config c;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n\n -- QA contact → the QA platform's CRM, never the real one (not even as a fallback). 005.\n v_qa := v_qa_pat IS NOT NULL AND v_email IS NOT NULL AND v_email ILIKE v_qa_pat;\n IF v_qa THEN\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_qa_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN\n RETURN jsonb_build_object('skipped', 'qa crm tenant not found', 'qa', true,\n 'qa_crm_tenant_slug', v_qa_slug, 'tag', p_tag);\n END IF;\n ELSE\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_crm_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n END IF;\n\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- The origem (sourceName) is written when the lead is BORN; a later tag on the same person goes\n -- to its submissions. The signup is the birth now, so it names the origem.\n v_origin := CASE WHEN p_tag = 'cadastro_v2' THEN 'Cadastro V2' ELSE 'Teste grátis — ' || coalesce(v_slug, '') END;\n\n -- Dedupe by e-mail/phone is create_public_lead's own: the signup lead and the trial lead are one person.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(CASE WHEN length(btrim(v_name)) < 2 THEN rpad(btrim(v_name), 2, '.') ELSE btrim(v_name) END, 120), v_phone, v_email,\n 'getting-started', v_origin,\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n\n -- ── the deal moves forward to the stage this tag names ─────────────────────\n v_pattern := nullif(btrim(coalesce(v_patterns ->> p_tag, '')), '');\n IF v_lead IS NOT NULL AND v_pattern IS NOT NULL AND to_regclass('public.plg_crm_deals') IS NOT NULL THEN\n -- The card is born by crm_lead_to_board; a lead without one (trigger failed, hand-made) gets it now.\n IF NOT EXISTS (SELECT 1 FROM public.plg_crm_deals d WHERE d.lead_id = v_lead)\n AND to_regprocedure('public.crm_place_lead_on_board(uuid)') IS NOT NULL THEN\n PERFORM public.crm_place_lead_on_board(v_lead);\n END IF;\n SELECT d.id, d.status, d.pipeline_id, d.stage_id, s.name AS stage_name, s.\"order\" AS stage_order,\n coalesce(s.is_won, false) OR coalesce(s.is_lost, false) AS closed\n INTO v_deal\n FROM public.plg_crm_deals d\n LEFT JOIN public.plg_crm_pipeline_stages s ON s.id = d.stage_id\n WHERE d.lead_id = v_lead AND d.tenant_id = v_crm\n LIMIT 1;\n IF v_deal.id IS NULL THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'no deal for the lead');\n ELSIF v_deal.status <> 'open' OR v_deal.closed THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'deal is closed', 'deal_id', v_deal.id);\n ELSE\n SELECT s.id, s.name, s.\"order\", s.probability INTO v_target\n FROM public.plg_crm_pipeline_stages s\n WHERE s.pipeline_id = v_deal.pipeline_id AND NOT coalesce(s.is_won, false) AND NOT coalesce(s.is_lost, false)\n AND s.name ILIKE v_pattern\n ORDER BY s.\"order\", s.created_at\n LIMIT 1;\n IF v_target.id IS NULL THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'no stage matches', 'pattern', v_pattern, 'deal_id', v_deal.id);\n ELSIF v_deal.stage_id = v_target.id THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'already there', 'stage', v_target.name, 'deal_id', v_deal.id);\n ELSIF v_deal.stage_order IS NOT NULL AND v_deal.stage_order > v_target.\"order\" THEN\n -- Sales already moved it further (proposal, negotiation): the journey never pulls it back.\n v_stage := jsonb_build_object('moved', false, 'reason', 'deal is ahead', 'stage', v_deal.stage_name, 'deal_id', v_deal.id);\n ELSE\n UPDATE public.plg_crm_deals\n SET stage_id = v_target.id, probability = coalesce(v_target.probability, probability)\n WHERE id = v_deal.id;\n v_stage := jsonb_build_object('moved', true, 'from', v_deal.stage_name, 'to', v_target.name, 'deal_id', v_deal.id);\n END IF;\n END IF;\n END IF;\n\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag)\n || CASE WHEN v_stage IS NOT NULL THEN jsonb_build_object('stage', v_stage) ELSE '{}'::jsonb END\n || CASE WHEN v_qa THEN jsonb_build_object('qa', true, 'crm_tenant_slug', v_qa_slug) ELSE '{}'::jsonb END;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- ── the mark: the binding became active for a house that is signing up ────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_binding()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_t record;\n v_j record;\n v_payload jsonb;\nBEGIN\n IF NEW.plugin_id <> 'getting-started' OR NEW.facet <> '' OR NEW.status <> 'active' THEN RETURN NULL; END IF;\n IF TG_OP = 'UPDATE' AND OLD.status = 'active' THEN RETURN NULL; END IF;\n -- Never fails the signup: an account created without its lead is recoverable, a lost signup is not.\n BEGIN\n -- Once per tenant, ever: switching the module off and on is not a second signup.\n IF EXISTS (SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = NEW.tenant_id AND e.kind = 'signed_up') THEN\n RETURN NULL;\n END IF;\n SELECT t.plan, t.trial_ends_at, t.created_at INTO v_t FROM public.tenants t WHERE t.id = NEW.tenant_id;\n IF NOT FOUND\n OR coalesce(nullif(btrim(v_t.plan), ''), 'free') <> 'free'\n OR v_t.trial_ends_at IS NULL\n OR v_t.created_at < now() - interval '48 hours' THEN\n RETURN NULL;\n END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j\n FROM public.plg_getting_started_journeys j WHERE j.tenant_id = NEW.tenant_id;\n IF v_j.trial_started_at IS NOT NULL OR v_j.converted_at IS NOT NULL THEN\n RETURN NULL;\n END IF;\n\n v_payload := jsonb_build_object('source', 'cadastro_v2', 'binding_source', NEW.source);\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (NEW.tenant_id, 'signed_up', auth.uid(), v_payload)\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n -- plg_emit_event refuses a caller who is not yet a member, and inside create_tenant the\n -- membership comes after the binding: file the fact directly (002's helper).\n PERFORM public.plg_getting_started_platform_emit(NEW.tenant_id, 'getting_started.signed_up', v_payload);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING '[getting-started] signed_up not recorded for tenant %: %', NEW.tenant_id, SQLERRM;\n END;\n RETURN NULL;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_binding() FROM PUBLIC, anon, authenticated;\n\nDROP TRIGGER IF EXISTS plg_getting_started_signed_up ON app.tenant_plugins;\nCREATE TRIGGER plg_getting_started_signed_up\n AFTER INSERT OR UPDATE OF status ON app.tenant_plugins\n FOR EACH ROW\n WHEN (NEW.plugin_id = 'getting-started' AND NEW.status = 'active')\n EXECUTE FUNCTION public.plg_getting_started_on_binding();\n\n-- ── the handler accepts only what the trigger wrote (003's rule) ──────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_signed_up(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'signed_up') THEN\n RETURN jsonb_build_object('skipped', 'signed_up was not written by the getting-started binding');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'cadastro_v2', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_signed_up(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_signed_up(jsonb) TO service_role;\n\nSELECT public.register_event_subscription('getting-started', 'getting_started.signed_up', 'plg_getting_started_on_signed_up',\n 'Faya lead when the workspace is created (cadastro V2)', 5);\n`\n\nexport const MIGRATIONS: Array<{ id: string; sql: string }> = [\n { id: \"000_journey\", sql: MIGRATION_000_JOURNEY },\n { id: \"001_trial_clock\", sql: MIGRATION_001_TRIAL_CLOCK },\n { id: \"002_platform_operator\", sql: MIGRATION_002_PLATFORM_OPERATOR },\n { id: \"003_the_clock_starts_once_and_only_for_who_may\", sql: MIGRATION_003_THE_CLOCK_STARTS_ONCE_AND_ONLY_FOR_WHO_MAY },\n { id: \"004_lead_keeps_the_name\", sql: MIGRATION_004_LEAD_KEEPS_THE_NAME },\n { id: \"005_qa_leads_go_to_qa_platform\", sql: MIGRATION_005_QA_LEADS_GO_TO_QA_PLATFORM },\n { id: \"006_the_lead_is_born_at_signup\", sql: MIGRATION_006_THE_LEAD_IS_BORN_AT_SIGNUP },\n]\n"]}
1
+ {"version":3,"sources":["../../src/migrations/index.ts"],"names":[],"mappings":";AAIO,IAAM,qBAAA,GAAwB,CAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqJ9B,IAAM,yBAAA,GAA4B,CAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA0TlC,IAAM,+BAAA,GAAkC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkQxC,IAAM,wDAAA,GAA2D,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0MjE,IAAM,iCAAA,GAAoC,CAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AA8D1C,IAAM,wCAAA,GAA2C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4GjD,IAAM,wCAAA,GAA2C,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAuOjD,IAAM,iDAAA,GAAoD,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAuP1D,IAAM,UAAA,GAAiD;AAAA,EAC5D,EAAE,EAAA,EAAI,aAAA,EAAe,GAAA,EAAK,qBAAA,EAAsB;AAAA,EAChD,EAAE,EAAA,EAAI,iBAAA,EAAmB,GAAA,EAAK,yBAAA,EAA0B;AAAA,EACxD,EAAE,EAAA,EAAI,uBAAA,EAAyB,GAAA,EAAK,+BAAA,EAAgC;AAAA,EACpE,EAAE,EAAA,EAAI,gDAAA,EAAkD,GAAA,EAAK,wDAAA,EAAyD;AAAA,EACtH,EAAE,EAAA,EAAI,yBAAA,EAA2B,GAAA,EAAK,iCAAA,EAAkC;AAAA,EACxE,EAAE,EAAA,EAAI,gCAAA,EAAkC,GAAA,EAAK,wCAAA,EAAyC;AAAA,EACtF,EAAE,EAAA,EAAI,gCAAA,EAAkC,GAAA,EAAK,wCAAA,EAAyC;AAAA,EACtF,EAAE,EAAA,EAAI,yCAAA,EAA2C,GAAA,EAAK,iDAAA;AACxD","file":"main.js","sourcesContent":["// AUTO-GENERATED from 000_journey.sql, 001_trial_clock.sql, 002_platform_operator.sql, 003_the_clock_starts_once_and_only_for_who_may.sql, 004_lead_keeps_the_name.sql, 005_qa_leads_go_to_qa_platform.sql, 006_the_lead_is_born_at_signup.sql, 007_the_trial_clock_starts_at_the_click.sql — regenerate with scripts/embed-migrations.mjs\n// SQL files are the source of truth; this inline copy lets the manifest declare\n// migrations as data. Do not edit by hand — run the embed script instead.\n\nexport const MIGRATION_000_JOURNEY = `-- 000_journey.sql — the getting-started plugin keeps the journey; tenants.trial_ends_at stays the end of the trial.\n\n-- ── platform config (one row; only service_role/postgres write) ───────────────\nCREATE TABLE IF NOT EXISTS public.plg_getting_started_config (\n id boolean PRIMARY KEY DEFAULT true CHECK (id),\n trial_days integer NOT NULL DEFAULT 14 CHECK (trial_days BETWEEN 1 AND 90),\n -- Documents the spine DEFAULT (db 044); never changes it.\n setup_window_days integer NOT NULL DEFAULT 30,\n crm_tenant_slug text NOT NULL DEFAULT 'controlgroup',\n reminder_hour_utc integer NOT NULL DEFAULT 13 CHECK (reminder_hour_utc BETWEEN 0 AND 23),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nINSERT INTO public.plg_getting_started_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING;\nALTER TABLE public.plg_getting_started_config ENABLE ROW LEVEL SECURITY;\nREVOKE ALL ON public.plg_getting_started_config FROM PUBLIC, anon, authenticated;\n-- Read by the view and the RPCs; no write policy on purpose.\nGRANT SELECT ON public.plg_getting_started_config TO authenticated;\nDROP POLICY IF EXISTS plg_getting_started_config_read ON public.plg_getting_started_config;\nCREATE POLICY plg_getting_started_config_read ON public.plg_getting_started_config\n FOR SELECT TO authenticated USING (true);\n\n-- ── the journey: one row per tenant ───────────────────────────────────────────\nCREATE TABLE IF NOT EXISTS public.plg_getting_started_journeys (\n tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,\n ready_at timestamptz,\n trial_started_at timestamptz,\n trial_days integer,\n converted_at timestamptz,\n wants_contract_at timestamptz,\n source text,\n utm jsonb NOT NULL DEFAULT '{}'::jsonb,\n answers_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_getting_started_journeys ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_getting_started_journeys FORCE ROW LEVEL SECURITY;\n-- Writes go through the RPCs only.\nGRANT SELECT ON public.plg_getting_started_journeys TO authenticated;\nDROP POLICY IF EXISTS plg_getting_started_journeys_select ON public.plg_getting_started_journeys;\nCREATE POLICY plg_getting_started_journeys_select ON public.plg_getting_started_journeys\n FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n\n-- ── timeline + idempotency lock (one row per tenant, kind and day) ────────────\nCREATE TABLE IF NOT EXISTS public.plg_getting_started_events (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n kind text NOT NULL CHECK (kind ~ '^[a-z][a-z0-9_]*$'),\n day date NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')::date,\n channel text,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n actor_id uuid,\n created_at timestamptz NOT NULL DEFAULT now()\n);\nCREATE UNIQUE INDEX IF NOT EXISTS plg_getting_started_events_once_a_day\n ON public.plg_getting_started_events (tenant_id, kind, day);\nCREATE INDEX IF NOT EXISTS plg_getting_started_events_tenant\n ON public.plg_getting_started_events (tenant_id, created_at DESC);\nALTER TABLE public.plg_getting_started_events ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_getting_started_events FORCE ROW LEVEL SECURITY;\nGRANT SELECT ON public.plg_getting_started_events TO authenticated;\nDROP POLICY IF EXISTS plg_getting_started_events_select ON public.plg_getting_started_events;\nCREATE POLICY plg_getting_started_events_select ON public.plg_getting_started_events\n FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));\n\n-- ── wizard keys this plugin owns (tech design, decision 5) ────────────────────\nSELECT app.seed_setting_keys($$[\n { \"key\": \"store.hours\", \"type\": \"object\", \"default\": {}, \"reader\": \"plugin:getting-started\",\n \"description\": \"Opening hours per weekday: {\\\\\"mon\\\\\":[{\\\\\"open\\\\\":\\\\\"11:00\\\\\",\\\\\"close\\\\\":\\\\\"23:00\\\\\"}],…}. Empty = not informed.\" },\n { \"key\": \"store.payment_methods\", \"type\": \"array\", \"default\": [], \"reader\": \"plugin:getting-started\",\n \"description\": \"Payment methods accepted at the counter and on delivery: cash, pix, credit, debit, voucher.\" },\n { \"key\": \"store.welcome_article\", \"type\": \"string\", \"default\": \"a\", \"reader\": \"plugin:getting-started\",\n \"description\": \"Article before the store name in greetings: a | o.\" }\n]$$::jsonb);\n\n-- ── the phase, derived (decision 1 and §3) ────────────────────────────────────\n-- security_invoker: the RLS of tenants decides which rows are visible (same pattern as v_team_members, db 004).\nCREATE OR REPLACE VIEW public.v_getting_started_journey WITH (security_invoker = true) AS\nSELECT t.id AS tenant_id,\n j.ready_at, j.trial_started_at, j.converted_at, j.wants_contract_at, j.source, j.utm,\n coalesce(j.trial_days, c.trial_days) AS trial_days,\n t.trial_ends_at,\n CASE WHEN j.trial_started_at IS NULL THEN t.trial_ends_at END AS setup_deadline_at,\n CASE\n WHEN j.converted_at IS NOT NULL THEN 'converted'\n WHEN t.trial_ends_at IS NOT NULL AND t.trial_ends_at <= now() THEN 'expired'\n WHEN j.trial_started_at IS NOT NULL THEN 'trial'\n ELSE 'setup'\n END AS phase\n FROM public.tenants t\n CROSS JOIN public.plg_getting_started_config c\n LEFT JOIN public.plg_getting_started_journeys j ON j.tenant_id = t.id;\nGRANT SELECT ON public.v_getting_started_journey TO authenticated;\n\n-- ── the minimums, one function (used by the start RPC and by the read) ────────\nCREATE OR REPLACE FUNCTION public.getting_started_minimums(p_tenant uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_menu boolean := false;\n v_hours boolean := false;\n v_order boolean := false;\n v_settings jsonb;\nBEGIN\n SELECT t.settings INTO v_settings FROM public.tenants t WHERE t.id = p_tenant;\n v_hours := jsonb_typeof(v_settings #> '{store,hours}') = 'object'\n AND (v_settings #> '{store,hours}') <> '{}'::jsonb;\n -- A pool without plugin-menu must not break the read.\n IF to_regclass('public.plg_menu_items') IS NOT NULL THEN\n EXECUTE $q$SELECT EXISTS (\n SELECT 1 FROM public.plg_menu_items mi\n JOIN public.products p ON p.id = mi.product_id\n WHERE mi.tenant_id = $1 AND mi.status = 'available' AND p.is_active\n AND coalesce(p.metadata->>'demo','') <> 'true')$q$\n INTO v_menu USING p_tenant;\n END IF;\n SELECT EXISTS (\n SELECT 1 FROM public.orders o\n WHERE o.tenant_id = p_tenant AND o.status = 'completed'\n AND o.metadata @> '{\"test\":true}'::jsonb\n ) INTO v_order;\n RETURN jsonb_build_object(\n 'menu', v_menu, 'hours', v_hours, 'test_order', v_order,\n 'ready', v_menu AND v_hours AND v_order\n );\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_minimums(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_minimums(uuid) TO authenticated, service_role;\n\n-- ── the read for the client ───────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_journey()\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_row record;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'getting_started_journey: no active tenant' USING ERRCODE = '42501';\n END IF;\n SELECT * INTO v_row FROM public.v_getting_started_journey v WHERE v.tenant_id = v_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('tenant_id', v_tenant, 'phase', 'setup',\n 'minimums', public.getting_started_minimums(v_tenant));\n END IF;\n RETURN to_jsonb(v_row) || jsonb_build_object('minimums', public.getting_started_minimums(v_tenant));\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_journey() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_journey() TO authenticated, service_role;\n`\n\nexport const MIGRATION_001_TRIAL_CLOCK = `-- 001_trial_clock.sql — start, end, reminders and the Faya lead. Depends on 000_journey.sql.\n\n-- ── start the trial ───────────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_start_trial(p_tenant uuid DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());\n v_days integer;\n v_min jsonb;\n v_started timestamptz;\n v_ends timestamptz;\nBEGIN\n -- Same gate as tenant_setting_set: a session, the tenant in scope, settings.manage.\n IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'getting_started_start_trial: no session or tenant out of scope' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('settings.manage') THEN\n RAISE EXCEPTION 'getting_started_start_trial: settings.manage is required' USING ERRCODE = '42501';\n END IF;\n\n -- Idempotent: a second call returns the state and leaves the date alone.\n SELECT j.trial_started_at INTO v_started FROM public.plg_getting_started_journeys j WHERE j.tenant_id = v_tenant;\n IF v_started IS NOT NULL THEN\n RETURN jsonb_build_object('ok', true, 'already_started', true, 'trial_started_at', v_started,\n 'trial_ends_at', (SELECT t.trial_ends_at FROM public.tenants t WHERE t.id = v_tenant));\n END IF;\n\n -- Minimums are checked here, not in the button.\n v_min := public.getting_started_minimums(v_tenant);\n IF NOT coalesce((v_min ->> 'ready')::boolean, false) THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'minimums_missing', 'minimums', v_min);\n END IF;\n\n SELECT c.trial_days INTO v_days FROM public.plg_getting_started_config c;\n v_days := coalesce(v_days, 14);\n v_started := now();\n v_ends := v_started + make_interval(days => v_days);\n\n INSERT INTO public.plg_getting_started_journeys (tenant_id, ready_at, trial_started_at, trial_days)\n VALUES (v_tenant, v_started, v_started, v_days)\n ON CONFLICT (tenant_id) DO UPDATE\n SET ready_at = coalesce(public.plg_getting_started_journeys.ready_at, EXCLUDED.ready_at),\n trial_started_at = EXCLUDED.trial_started_at,\n trial_days = EXCLUDED.trial_days,\n updated_at = now();\n\n -- The real clock. Passes tenants_trial_is_readonly because SECURITY DEFINER runs as the owner (postgres).\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = v_tenant;\n\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (v_tenant, 'trial_started', auth.uid(), jsonb_build_object('trial_days', v_days, 'ends_at', v_ends))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n\n PERFORM public.plg_emit_event('getting_started.trial_started',\n jsonb_build_object('trial_days', v_days, 'trial_ends_at', v_ends), 'tenant', v_tenant::text, v_tenant);\n\n RETURN jsonb_build_object('ok', true, 'trial_started_at', v_started, 'trial_ends_at', v_ends, 'trial_days', v_days);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;\n\n-- Agent executor shape (p_tenant_id, p_actor_user_id, p_payload), as agent_crm_create_lead.\nCREATE OR REPLACE FUNCTION public.agent_getting_started_start_trial(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n IF auth.uid() IS NOT NULL AND auth.uid() IS DISTINCT FROM p_actor_user_id THEN\n RAISE EXCEPTION 'agent_getting_started_start_trial: actor mismatch' USING ERRCODE = '42501';\n END IF;\n RETURN public.getting_started_start_trial(p_tenant_id);\nEND $$;\nREVOKE ALL ON FUNCTION public.agent_getting_started_start_trial(uuid, uuid, jsonb) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.agent_getting_started_start_trial(uuid, uuid, jsonb) TO authenticated, service_role;\n\n-- ── \"I want to subscribe\" ─────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_request_contract(p_tenant uuid DEFAULT NULL, p_note text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());\n v_inserted boolean;\nBEGIN\n IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'getting_started_request_contract: no session or tenant out of scope' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('settings.manage') THEN\n RAISE EXCEPTION 'getting_started_request_contract: settings.manage is required' USING ERRCODE = '42501';\n END IF;\n\n -- Rate limit: the timeline's one-per-day lock. The V1 had none.\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (v_tenant, 'wants_contract', auth.uid(), jsonb_build_object('note', left(coalesce(p_note, ''), 500)))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING\n RETURNING true INTO v_inserted;\n IF NOT coalesce(v_inserted, false) THEN\n RETURN jsonb_build_object('ok', true, 'already_requested_today', true);\n END IF;\n\n INSERT INTO public.plg_getting_started_journeys (tenant_id, wants_contract_at)\n VALUES (v_tenant, now())\n ON CONFLICT (tenant_id) DO UPDATE\n SET wants_contract_at = coalesce(public.plg_getting_started_journeys.wants_contract_at, now()),\n updated_at = now();\n\n PERFORM public.plg_emit_event('getting_started.wants_contract',\n jsonb_build_object('note', left(coalesce(p_note, ''), 500)), 'tenant', v_tenant::text, v_tenant);\n\n RETURN jsonb_build_object('ok', true);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_request_contract(uuid, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_request_contract(uuid, text) TO authenticated, service_role;\n\n-- ── reminders D−3 / D0 ────────────────────────────────────────────────────────\n-- A tenant's calendar day. A bad zone name must not take the whole run down.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_local_date(p_at timestamptz, p_tz text)\nRETURNS date LANGUAGE plpgsql STABLE SET search_path TO '' AS $$\nBEGIN\n RETURN (p_at AT TIME ZONE coalesce(nullif(p_tz, ''), 'UTC'))::date;\nEXCEPTION WHEN OTHERS THEN\n RETURN (p_at AT TIME ZONE 'UTC')::date;\nEND $$;\n\n-- Who is due today. \\`p_now\\` is injectable so QA can simulate the day without touching the clock.\n-- Due-ness is by the tenant's local day; the once-a-day lock is by the run's UTC day, so a\n-- reminder sent before an extend does not block the one the new end date earns.\nCREATE OR REPLACE FUNCTION public.getting_started_reminders_due(p_now timestamptz DEFAULT now())\nRETURNS TABLE (tenant_id uuid, kind text, trial_ends_at timestamptz)\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$\n WITH due AS (\n SELECT j.tenant_id, t.trial_ends_at,\n public.plg_getting_started_local_date(t.trial_ends_at, t.settings ->> 'timezone') AS end_day,\n public.plg_getting_started_local_date(p_now, t.settings ->> 'timezone') AS today\n FROM public.plg_getting_started_journeys j\n JOIN public.tenants t ON t.id = j.tenant_id\n WHERE j.trial_started_at IS NOT NULL\n AND j.converted_at IS NULL\n AND t.trial_ends_at IS NOT NULL\n )\n SELECT d.tenant_id,\n CASE WHEN d.end_day = d.today + 3 THEN 'reminder_d3' ELSE 'reminder_d0' END AS kind,\n d.trial_ends_at\n FROM due d\n WHERE d.end_day IN (d.today + 3, d.today)\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = d.tenant_id\n AND e.day = (p_now AT TIME ZONE 'UTC')::date\n AND e.kind = CASE WHEN d.end_day = d.today + 3 THEN 'reminder_d3' ELSE 'reminder_d0' END\n );\n$$;\nREVOKE ALL ON FUNCTION public.getting_started_reminders_due(timestamptz) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.getting_started_reminders_due(timestamptz) TO service_role;\n\n-- Templates are per tenant (notifications UNIQUE (tenant_id, key, channel, locale)).\nCREATE OR REPLACE FUNCTION public.getting_started_ensure_templates(p_tenant uuid)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n IF to_regclass('public.plg_notifications_templates') IS NULL THEN RETURN; END IF;\n INSERT INTO public.plg_notifications_templates (tenant_id, key, channel, locale, subject, body, variables, is_default)\n VALUES\n (p_tenant, 'getting_started.reminder_d3', 'in_app', 'pt-BR', 'Seu teste termina em 3 dias',\n 'Faltam 3 dias de teste grátis. Até lá está tudo liberado. Para continuar depois, é só escolher um plano em Configurações › Assinatura — sem fidelidade.',\n '[\"trial_ends_at\"]'::jsonb, true),\n (p_tenant, 'getting_started.reminder_d0', 'in_app', 'pt-BR', 'Hoje é o último dia do seu teste',\n 'Termina hoje. Depois disso a loja continua e seus dados também; o que é do plano pago fica fechado até você assinar.',\n '[\"trial_ends_at\"]'::jsonb, true)\n ON CONFLICT (tenant_id, key, channel, locale) DO NOTHING;\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_ensure_templates(uuid) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.getting_started_ensure_templates(uuid) TO service_role;\n\n-- The job. Writes the event BEFORE enqueueing (the lock), then enqueues through notifications when present.\nCREATE OR REPLACE FUNCTION public.getting_started_reminders_run(p_now timestamptz DEFAULT now())\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n r record;\n v_day date := (p_now AT TIME ZONE 'UTC')::date;\n v_person uuid;\n v_sent integer := 0;\n v_skipped integer := 0;\n v_queue uuid;\n v_enqueue boolean := to_regprocedure('public.notifications_enqueue(uuid,text,uuid,jsonb,text,timestamptz,text,text,uuid,uuid)') IS NOT NULL;\nBEGIN\n IF NOT pg_try_advisory_xact_lock(hashtext('getting_started_reminders')) THEN\n RETURN jsonb_build_object('skipped', 'another run is active');\n END IF;\n\n FOR r IN SELECT * FROM public.getting_started_reminders_due(p_now) LOOP\n -- The owner: the team person whose e-mail is the first active member's.\n SELECT pe.id INTO v_person\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = r.tenant_id AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, day, channel, payload)\n VALUES (r.tenant_id, r.kind, v_day, CASE WHEN v_person IS NULL THEN 'none' ELSE 'in_app' END,\n jsonb_build_object('trial_ends_at', r.trial_ends_at))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n IF NOT FOUND THEN v_skipped := v_skipped + 1; CONTINUE; END IF;\n\n IF v_person IS NULL OR NOT v_enqueue THEN\n v_skipped := v_skipped + 1; CONTINUE;\n END IF;\n\n PERFORM public.getting_started_ensure_templates(r.tenant_id);\n BEGIN\n EXECUTE 'SELECT public.notifications_enqueue($1, $2, $3, $4, $5, $6, $7)'\n INTO v_queue\n USING r.tenant_id, 'getting_started.' || r.kind, v_person,\n jsonb_build_object('trial_ends_at', r.trial_ends_at),\n 'getting_started:' || r.kind || ':' || r.tenant_id || ':' || v_day, p_now, 'in_app';\n v_sent := v_sent + 1;\n EXCEPTION WHEN OTHERS THEN\n -- A provider down for one tenant does not stop the others; the event already marked the day.\n UPDATE public.plg_getting_started_events SET payload = payload || jsonb_build_object('error', SQLERRM)\n WHERE tenant_id = r.tenant_id AND kind = r.kind AND day = v_day;\n v_skipped := v_skipped + 1;\n END;\n END LOOP;\n\n RETURN jsonb_build_object('sent', v_sent, 'skipped', v_skipped);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_reminders_run(timestamptz) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.getting_started_reminders_run(timestamptz) TO service_role;\n\n-- cron: the spine's guard, plus a row in app.scheduled_jobs so the schedule is visible without pg_cron.\nDO $cron$\nDECLARE v_hour integer;\nBEGIN\n SELECT reminder_hour_utc INTO v_hour FROM public.plg_getting_started_config;\n v_hour := coalesce(v_hour, 13);\n INSERT INTO app.scheduled_jobs (job_name, schedule, command, scheduled, reason)\n VALUES ('fayz_getting_started_reminders', format('0 %s * * *', v_hour), 'SELECT public.getting_started_reminders_run()', false, 'pending')\n ON CONFLICT (job_name) DO UPDATE\n SET schedule = EXCLUDED.schedule, command = EXCLUDED.command, updated_at = now();\n IF to_regprocedure('cron.schedule(text, text, text)') IS NULL THEN\n UPDATE app.scheduled_jobs SET scheduled = false, reason = 'pg_cron absent' WHERE job_name = 'fayz_getting_started_reminders';\n RAISE NOTICE 'getting-started: pg_cron absent — reminders not scheduled';\n RETURN;\n END IF;\n PERFORM cron.unschedule(jobid) FROM cron.job WHERE jobname = 'fayz_getting_started_reminders';\n PERFORM cron.schedule('fayz_getting_started_reminders', format('0 %s * * *', v_hour), $job$SELECT public.getting_started_reminders_run()$job$);\n UPDATE app.scheduled_jobs SET scheduled = true, reason = NULL WHERE job_name = 'fayz_getting_started_reminders';\nEND $cron$;\n\n-- ── the lead in Faya's CRM ────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug INTO v_slug FROM public.plg_getting_started_config c;\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(rpad(btrim(v_name), 2, '.'), 120), v_phone, v_email,\n 'getting-started', 'Teste grátis — ' || coalesce(v_slug, ''),\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- Handlers in the shape consume_event_log runs: public.<handler>(jsonb), where the argument is\n-- to_jsonb(event_log row) — so \\`tenant_id\\` and \\`payload\\` are top-level keys.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$\n SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\n$$;\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_wants_contract(p_event jsonb)\nRETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$\n SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'quer_contratar', coalesce(p_event -> 'payload', '{}'::jsonb));\n$$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) TO service_role;\n\nSELECT public.register_event_subscription('getting-started', 'getting_started.trial_started', 'plg_getting_started_on_trial_started', 'Faya lead when the trial starts', 5);\nSELECT public.register_event_subscription('getting-started', 'getting_started.wants_contract', 'plg_getting_started_on_wants_contract', 'Faya lead when the tenant wants to subscribe', 5);\n`\n\nexport const MIGRATION_002_PLATFORM_OPERATOR = `-- 002_platform_operator.sql — the platform reads every tenant's trial and acts on it.\n-- Functions only; no new table. Depends on 000_journey.sql and 001_trial_clock.sql.\n--\n-- \"Platform operator\" = an active member of the tenant named by\n-- plg_getting_started_config.crm_tenant_slug, acting INSIDE that tenant\n-- (app.current_tenant_id()), or — where the live cluster has the platform\n-- schema — platform.can(auth.uid(), 'tenants.write'). Same shape as\n-- support_is_operator(): the boundary is a SECURITY DEFINER function with a\n-- guard; no RLS policy opens between tenants.\n\n-- ── who is the platform ───────────────────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_platform_tenant()\nRETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$\n SELECT t.id\n FROM public.plg_getting_started_config c\n JOIN public.tenants t ON t.slug = c.crm_tenant_slug\n LIMIT 1;\n$$;\nREVOKE ALL ON FUNCTION public.getting_started_platform_tenant() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_platform_tenant() TO authenticated, service_role;\n\nCREATE OR REPLACE FUNCTION public.getting_started_is_platform_operator()\nRETURNS boolean LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_uid uuid := auth.uid();\n v_platform uuid := public.getting_started_platform_tenant();\n v_can boolean := false;\nBEGIN\n IF v_uid IS NULL THEN RETURN false; END IF;\n IF v_platform IS NOT NULL AND v_platform = app.current_tenant_id() AND EXISTS (\n SELECT 1 FROM app.memberships m\n WHERE m.tenant_id = v_platform AND m.user_id = v_uid AND m.active) THEN\n RETURN true;\n END IF;\n -- Optional reinforcement: the platform schema lives only on the live cluster.\n IF to_regprocedure('platform.can(uuid,text)') IS NOT NULL THEN\n EXECUTE 'SELECT platform.can($1, $2)' INTO v_can USING v_uid, 'tenants.write';\n END IF;\n RETURN coalesce(v_can, false);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_is_platform_operator() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_is_platform_operator() TO authenticated, service_role;\n\nCREATE OR REPLACE FUNCTION public.getting_started_platform_guard()\nRETURNS void LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n IF NOT public.getting_started_is_platform_operator() THEN\n RAISE EXCEPTION 'not a platform operator' USING ERRCODE = '42501';\n END IF;\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_platform_guard() FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_platform_guard() TO authenticated, service_role;\n\n-- ── timeline note that survives a repeat on the same day ──────────────────────\n-- plg_getting_started_events is UNIQUE (tenant_id, kind, day). A second\n-- extension on the same day keeps one row and folds the latest payload in,\n-- counting the repeats; the customer-success timeline keeps every action.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_note(p_tenant uuid, p_kind text, p_payload jsonb, p_actor uuid DEFAULT auth.uid())\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nBEGIN\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (p_tenant, p_kind, p_actor, coalesce(p_payload, '{}'::jsonb))\n ON CONFLICT (tenant_id, kind, day) DO UPDATE\n SET payload = public.plg_getting_started_events.payload || EXCLUDED.payload\n || jsonb_build_object('repeats', coalesce((public.plg_getting_started_events.payload ->> 'repeats')::int, 0) + 1),\n actor_id = coalesce(EXCLUDED.actor_id, public.plg_getting_started_events.actor_id);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_note(uuid, text, jsonb, uuid) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_note(uuid, text, jsonb, uuid) TO service_role;\n\n-- ── every tenant's journey, for the platform ──────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_platform_journeys(p_search text DEFAULT NULL, p_phase text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_platform uuid := public.getting_started_platform_tenant();\n v_search text := nullif(btrim(coalesce(p_search, '')), '');\n v_result jsonb;\nBEGIN\n PERFORM public.getting_started_platform_guard();\n SELECT coalesce(jsonb_agg(to_jsonb(r) ORDER BY r.tenant_created_at DESC), '[]'::jsonb) INTO v_result\n FROM (\n SELECT t.id AS tenant_id,\n t.name AS tenant_name,\n t.slug AS tenant_slug,\n t.vertical_id,\n t.plan AS pool_plan,\n t.settings #>> '{billing,plan_id}' AS grants_plan,\n t.kind,\n t.created_at AS tenant_created_at,\n coalesce((SELECT array_agg(ta.app_id ORDER BY ta.created_at)\n FROM app.tenant_apps ta WHERE ta.tenant_id = t.id AND ta.status = 'active'), '{}'::text[]) AS apps,\n coalesce((SELECT l.state FROM app.tenant_lifecycle l WHERE l.tenant_id = t.id), 'active') AS lifecycle_state,\n coalesce(j.phase, 'setup') AS phase,\n j.ready_at, j.trial_started_at, j.trial_ends_at, j.setup_deadline_at, j.converted_at, j.wants_contract_at,\n coalesce(j.trial_days, c.trial_days) AS trial_days,\n j.source, coalesce(j.utm, '{}'::jsonb) AS utm,\n public.getting_started_minimums(t.id) AS minimums,\n (SELECT jsonb_build_object('email', u.email, 'name', pe.name, 'phone', pe.phone)\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.id = m.person_id\n WHERE m.tenant_id = t.id AND m.active\n ORDER BY m.created_at LIMIT 1) AS contact,\n (SELECT jsonb_build_object('kind', e.kind, 'created_at', e.created_at)\n FROM public.plg_getting_started_events e\n WHERE e.tenant_id = t.id ORDER BY e.created_at DESC LIMIT 1) AS last_event,\n (SELECT count(*) FROM public.plg_getting_started_events e WHERE e.tenant_id = t.id) AS events_count\n FROM public.tenants t\n CROSS JOIN public.plg_getting_started_config c\n LEFT JOIN public.v_getting_started_journey j ON j.tenant_id = t.id\n WHERE (v_platform IS NULL OR t.id <> v_platform)\n AND (p_phase IS NULL OR coalesce(j.phase, 'setup') = p_phase)\n AND (v_search IS NULL\n OR t.name ILIKE '%' || v_search || '%'\n OR t.slug ILIKE '%' || v_search || '%'\n OR EXISTS (SELECT 1 FROM app.memberships m JOIN auth.users u ON u.id = m.user_id\n WHERE m.tenant_id = t.id AND m.active AND u.email ILIKE '%' || v_search || '%'))\n ) r;\n RETURN v_result;\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_platform_journeys(text, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_platform_journeys(text, text) TO authenticated, service_role;\n\n-- ── act on one tenant's trial ─────────────────────────────────────────────────\n-- extend : adds p_days (1..90) on top of greatest(trial_ends_at, now()) — V1 §6.6.\n-- end_now : trial_ends_at = now(); emits getting_started.expired.\n-- convert : converted_at = coalesce(converted_at, now()); emits getting_started.converted.\n-- remind : one in_app notice to the first member, template getting_started.reminder_d0.\n-- The operator is not a member of the house, and plg_emit_event refuses a caller outside\n-- the tenant. The guard above already authorised the platform; file the fact directly.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_platform_emit(p_tenant uuid, p_key text, p_payload jsonb)\nRETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE v_id uuid;\nBEGIN\n INSERT INTO public.event_log (tenant_id, event_key, payload, actor_id, subject_type, subject_id)\n VALUES (p_tenant, p_key, coalesce(p_payload, '{}'::jsonb), auth.uid(), 'tenant', p_tenant::text)\n RETURNING id INTO v_id;\n RETURN v_id;\nEXCEPTION WHEN OTHERS THEN\n RAISE WARNING '[getting_started_trial_set] % not recorded: %', p_key, SQLERRM;\n RETURN NULL;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_platform_emit(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_platform_emit(uuid, text, jsonb) TO service_role;\n\nCREATE OR REPLACE FUNCTION public.getting_started_trial_set(p_tenant uuid, p_action text, p_days integer DEFAULT NULL, p_reason text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_platform uuid := public.getting_started_platform_tenant();\n v_actor uuid := auth.uid();\n v_reason text := nullif(btrim(coalesce(p_reason, '')), '');\n v_t record;\n v_j record;\n v_before jsonb;\n v_after jsonb;\n v_payload jsonb := '{}'::jsonb;\n v_ends timestamptz;\n v_converted timestamptz;\n v_person uuid;\n v_nonce text;\n v_queue uuid;\n v_enqueue boolean := to_regprocedure('public.notifications_enqueue(uuid,text,uuid,jsonb,text,timestamptz,text,text,uuid,uuid)') IS NOT NULL;\n v_ok boolean := true;\nBEGIN\n PERFORM public.getting_started_platform_guard();\n IF p_action IS NULL OR p_action NOT IN ('extend', 'end_now', 'convert', 'remind') THEN\n RAISE EXCEPTION 'invalid action %', p_action USING ERRCODE = '22023';\n END IF;\n IF p_tenant IS NULL OR p_tenant = v_platform THEN\n RAISE EXCEPTION 'tenant out of scope' USING ERRCODE = '22023';\n END IF;\n SELECT t.id, t.trial_ends_at INTO v_t FROM public.tenants t WHERE t.id = p_tenant FOR UPDATE;\n IF NOT FOUND THEN RAISE EXCEPTION 'tenant not found' USING ERRCODE = 'P0002'; END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j FROM public.plg_getting_started_journeys j WHERE j.tenant_id = p_tenant;\n\n v_before := jsonb_build_object('trial_ends_at', v_t.trial_ends_at, 'trial_started_at', v_j.trial_started_at, 'converted_at', v_j.converted_at);\n v_ends := v_t.trial_ends_at;\n v_converted := v_j.converted_at;\n\n IF p_action = 'extend' THEN\n IF p_days IS NULL OR p_days NOT BETWEEN 1 AND 90 THEN\n RAISE EXCEPTION 'p_days must be between 1 and 90' USING ERRCODE = '22023';\n END IF;\n v_ends := greatest(coalesce(v_t.trial_ends_at, now()), now()) + make_interval(days => p_days);\n INSERT INTO public.plg_getting_started_journeys (tenant_id, trial_started_at, trial_days)\n VALUES (p_tenant, now(), (SELECT c.trial_days FROM public.plg_getting_started_config c))\n ON CONFLICT (tenant_id) DO UPDATE\n SET trial_started_at = coalesce(public.plg_getting_started_journeys.trial_started_at, EXCLUDED.trial_started_at),\n updated_at = now();\n -- Passes tenants_trial_is_readonly: SECURITY DEFINER runs as the owner.\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = p_tenant;\n v_payload := jsonb_build_object('days', p_days, 'ends_at', v_ends, 'reason', v_reason);\n PERFORM public.plg_getting_started_note(p_tenant, 'trial_extended', v_payload, v_actor);\n\n ELSIF p_action = 'end_now' THEN\n IF v_reason IS NULL THEN RAISE EXCEPTION 'a reason is required to end a trial' USING ERRCODE = '22023'; END IF;\n v_ends := now();\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = p_tenant;\n v_payload := jsonb_build_object('ends_at', v_ends, 'reason', v_reason);\n PERFORM public.plg_getting_started_note(p_tenant, 'trial_ended', v_payload, v_actor);\n PERFORM public.plg_getting_started_platform_emit(p_tenant, 'getting_started.expired',\n jsonb_build_object('trial_ends_at', v_ends, 'reason', v_reason, 'ended_by', v_actor));\n\n ELSIF p_action = 'convert' THEN\n -- Without a proposal the operator writes why; with one, the caller passes 'proposal:<number>'.\n IF v_reason IS NULL THEN RAISE EXCEPTION 'a reason is required to convert without a proposal' USING ERRCODE = '22023'; END IF;\n v_converted := coalesce(v_j.converted_at, now());\n INSERT INTO public.plg_getting_started_journeys (tenant_id, converted_at)\n VALUES (p_tenant, v_converted)\n ON CONFLICT (tenant_id) DO UPDATE\n SET converted_at = coalesce(public.plg_getting_started_journeys.converted_at, EXCLUDED.converted_at),\n updated_at = now();\n v_payload := jsonb_build_object('converted_at', v_converted, 'reason', v_reason);\n IF v_j.converted_at IS NULL THEN\n PERFORM public.plg_getting_started_note(p_tenant, 'converted', v_payload, v_actor);\n PERFORM public.plg_getting_started_platform_emit(p_tenant, 'getting_started.converted',\n jsonb_build_object('converted_at', v_converted, 'reason', v_reason, 'converted_by', v_actor));\n END IF;\n\n ELSIF p_action = 'remind' THEN\n SELECT pe.id INTO v_person\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at LIMIT 1;\n v_nonce := left(md5(random()::text || clock_timestamp()::text), 12);\n v_payload := jsonb_build_object('trial_ends_at', v_t.trial_ends_at, 'nonce', v_nonce, 'reason', v_reason,\n 'channel', CASE WHEN v_person IS NULL OR NOT v_enqueue THEN 'none' ELSE 'in_app' END);\n IF v_person IS NULL OR NOT v_enqueue THEN\n v_ok := false;\n v_payload := v_payload || jsonb_build_object('skipped', CASE WHEN v_person IS NULL THEN 'no contact' ELSE 'plugin-notifications absent' END);\n ELSE\n PERFORM public.getting_started_ensure_templates(p_tenant);\n EXECUTE 'SELECT public.notifications_enqueue($1, $2, $3, $4, $5, $6, $7)'\n INTO v_queue\n USING p_tenant, 'getting_started.reminder_d0', v_person,\n jsonb_build_object('trial_ends_at', v_t.trial_ends_at),\n 'getting_started:reminder_manual:' || p_tenant || ':' || v_nonce, now(), 'in_app';\n v_payload := v_payload || jsonb_build_object('queue_id', v_queue);\n END IF;\n PERFORM public.plg_getting_started_note(p_tenant, 'reminder_manual', v_payload, v_actor);\n END IF;\n\n v_after := jsonb_build_object('trial_ends_at', v_ends, 'converted_at', v_converted);\n -- audit_log derives the tenant from the session for a signed-in caller; the\n -- customer tenant travels in the metadata. A service_role run names the platform.\n PERFORM public.audit_log('getting_started.' || p_action, 'tenant', p_tenant, v_reason, v_before, v_after, NULL,\n v_payload || jsonb_build_object('customer_tenant_id', p_tenant),\n CASE WHEN v_actor IS NULL THEN v_platform ELSE NULL END);\n\n RETURN jsonb_build_object('ok', v_ok, 'action', p_action, 'tenant_id', p_tenant,\n 'trial_ends_at', v_ends, 'converted_at', v_converted);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_trial_set(uuid, text, integer, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_trial_set(uuid, text, integer, text) TO authenticated, service_role;\n`\n\nexport const MIGRATION_003_THE_CLOCK_STARTS_ONCE_AND_ONLY_FOR_WHO_MAY = `-- 003_the_clock_starts_once_and_only_for_who_may.sql — the gates the audit found open.\n-- Functions and grants only; no new table. Depends on 000, 001 and 002.\n--\n-- * getting_started_start_trial: the tenant row is locked (FOR UPDATE), so two\n-- clicks cannot start twice and emit twice; a tenant with no trial window\n-- (trial_ends_at IS NULL — legacy or paying, db 044), an already converted\n-- journey or a paid plan is refused instead of being handed a clock that\n-- expires on them.\n-- * getting_started_minimums: a member of the tenant (or the platform\n-- operator of 002, or service_role) — not any signed-in user probing other\n-- tenants by id.\n-- * plg_getting_started_config: the client reads only the columns it needs;\n-- crm_tenant_slug is the platform's business.\n-- * the event handlers accept only what the RPCs wrote — plg_emit_event is\n-- open to any member, and a hand-made \\`trial_started\\` must not create a lead.\n\n-- ── the minimums: only for who may ask ───────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_minimums(p_tenant uuid)\nRETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_menu boolean := false;\n v_hours boolean := false;\n v_order boolean := false;\n v_settings jsonb;\n v_operator boolean := false;\nBEGIN\n IF p_tenant IS NULL THEN\n RAISE EXCEPTION 'getting_started_minimums: tenant is required' USING ERRCODE = '22023';\n END IF;\n -- service_role (jobs), a member of the tenant, or the platform operator (002).\n IF auth.role() IS DISTINCT FROM 'service_role' AND NOT app.is_member(p_tenant) THEN\n IF to_regprocedure('public.getting_started_is_platform_operator()') IS NOT NULL THEN\n EXECUTE 'SELECT public.getting_started_is_platform_operator()' INTO v_operator;\n END IF;\n IF NOT coalesce(v_operator, false) THEN\n RAISE EXCEPTION 'getting_started_minimums: not a member of this tenant' USING ERRCODE = '42501';\n END IF;\n END IF;\n\n SELECT t.settings INTO v_settings FROM public.tenants t WHERE t.id = p_tenant;\n v_hours := jsonb_typeof(v_settings #> '{store,hours}') = 'object'\n AND (v_settings #> '{store,hours}') <> '{}'::jsonb;\n -- A pool without plugin-menu must not break the read.\n IF to_regclass('public.plg_menu_items') IS NOT NULL THEN\n EXECUTE $q$SELECT EXISTS (\n SELECT 1 FROM public.plg_menu_items mi\n JOIN public.products p ON p.id = mi.product_id\n WHERE mi.tenant_id = $1 AND mi.status = 'available' AND p.is_active\n AND coalesce(p.metadata->>'demo','') <> 'true')$q$\n INTO v_menu USING p_tenant;\n END IF;\n SELECT EXISTS (\n SELECT 1 FROM public.orders o\n WHERE o.tenant_id = p_tenant AND o.status = 'completed'\n AND o.metadata @> '{\"test\":true}'::jsonb\n ) INTO v_order;\n RETURN jsonb_build_object(\n 'menu', v_menu, 'hours', v_hours, 'test_order', v_order,\n 'ready', v_menu AND v_hours AND v_order\n );\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_minimums(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_minimums(uuid) TO authenticated, service_role;\n\n-- ── start the trial: once, locked, and only for a tenant that can be on trial ─\nCREATE OR REPLACE FUNCTION public.getting_started_start_trial(p_tenant uuid DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());\n v_days integer;\n v_min jsonb;\n v_t record;\n v_j record;\n v_started timestamptz;\n v_ends timestamptz;\nBEGIN\n -- Same gate as tenant_setting_set: a session, the tenant in scope, settings.manage.\n IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'getting_started_start_trial: no session or tenant out of scope' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('settings.manage') THEN\n RAISE EXCEPTION 'getting_started_start_trial: settings.manage is required' USING ERRCODE = '42501';\n END IF;\n\n -- The lock: a second concurrent call waits here and then sees the start.\n SELECT t.id, t.trial_ends_at, t.plan INTO v_t FROM public.tenants t WHERE t.id = v_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'getting_started_start_trial: tenant not found' USING ERRCODE = 'P0002';\n END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j\n FROM public.plg_getting_started_journeys j WHERE j.tenant_id = v_tenant FOR UPDATE;\n\n -- Idempotent: a second call returns the state and leaves the date alone.\n IF v_j.trial_started_at IS NOT NULL THEN\n RETURN jsonb_build_object('ok', true, 'already_started', true, 'trial_started_at', v_j.trial_started_at,\n 'trial_ends_at', v_t.trial_ends_at);\n END IF;\n\n v_min := public.getting_started_minimums(v_tenant);\n\n -- Who is already converted, or already paying, is not offered a clock that\n -- would expire on them. \\`trial_ends_at IS NULL\\` is the legacy/paying tenant\n -- (db 044): no window, no trial.\n IF v_j.converted_at IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'already_converted', 'minimums', v_min);\n END IF;\n IF coalesce(nullif(btrim(v_t.plan), ''), 'free') <> 'free' THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'already_paid', 'minimums', v_min, 'plan', v_t.plan);\n END IF;\n IF v_t.trial_ends_at IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'no_trial_window', 'minimums', v_min);\n END IF;\n\n -- Minimums are checked here, not in the button.\n IF NOT coalesce((v_min ->> 'ready')::boolean, false) THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'minimums_missing', 'minimums', v_min);\n END IF;\n\n SELECT c.trial_days INTO v_days FROM public.plg_getting_started_config c;\n v_days := coalesce(v_days, 14);\n v_started := now();\n v_ends := v_started + make_interval(days => v_days);\n\n INSERT INTO public.plg_getting_started_journeys (tenant_id, ready_at, trial_started_at, trial_days)\n VALUES (v_tenant, v_started, v_started, v_days)\n ON CONFLICT (tenant_id) DO UPDATE\n SET ready_at = coalesce(public.plg_getting_started_journeys.ready_at, EXCLUDED.ready_at),\n trial_started_at = EXCLUDED.trial_started_at,\n trial_days = EXCLUDED.trial_days,\n updated_at = now();\n\n -- The real clock. Passes tenants_trial_is_readonly (db 221): this DEFINER\n -- function runs as its owner, which the gate lets through.\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = v_tenant;\n\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (v_tenant, 'trial_started', auth.uid(), jsonb_build_object('trial_days', v_days, 'ends_at', v_ends))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n\n PERFORM public.plg_emit_event('getting_started.trial_started',\n jsonb_build_object('trial_days', v_days, 'trial_ends_at', v_ends), 'tenant', v_tenant::text, v_tenant);\n\n RETURN jsonb_build_object('ok', true, 'trial_started_at', v_started, 'trial_ends_at', v_ends, 'trial_days', v_days);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;\n\n-- ── the platform config: the client reads only what it needs ─────────────────\n-- The view (000) reads trial_days; the setup window is documentation of the\n-- spine default. crm_tenant_slug and reminder_hour_utc are read inside the\n-- DEFINER functions only. The policy stays (USING true); the GRANT narrows.\nREVOKE SELECT ON public.plg_getting_started_config FROM authenticated;\nGRANT SELECT (id, trial_days, setup_window_days) ON public.plg_getting_started_config TO authenticated;\n\n-- ── the handlers accept only what the RPCs wrote ──────────────────────────────\n-- plg_emit_event is open to any member of the tenant; the journey row and the\n-- timeline are written by DEFINER RPCs only (no write policy). So a lead is\n-- created only when the mark the RPC leaves is there.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.trial_started_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'trial_started') THEN\n RETURN jsonb_build_object('skipped', 'trial_started was not written by getting_started_start_trial');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_wants_contract(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.wants_contract_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'wants_contract'\n AND e.created_at >= now() - interval '2 days') THEN\n RETURN jsonb_build_object('skipped', 'wants_contract was not written by getting_started_request_contract');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'quer_contratar', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) TO service_role;\n`\n\nexport const MIGRATION_004_LEAD_KEEPS_THE_NAME = `-- 004_lead_keeps_the_name.sql — 001 padded the house name with rpad(name, 2), which also CUTS it to 2\n-- characters: every lead in the Faya CRM was born named \"Bu\". Pad only names shorter than 2.\n\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug INTO v_slug FROM public.plg_getting_started_config c;\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(CASE WHEN length(btrim(v_name)) < 2 THEN rpad(btrim(v_name), 2, '.') ELSE btrim(v_name) END, 120), v_phone, v_email,\n 'getting-started', 'Teste grátis — ' || coalesce(v_slug, ''),\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag);\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- Handlers in the shape consume_event_log runs: public.<handler>(jsonb), where the argument is\n-- to_jsonb(event_log row) — so \\`tenant_id\\` and \\`payload\\` are top-level keys.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$\n SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\n$$;\n\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n`\n\nexport const MIGRATION_005_QA_LEADS_GO_TO_QA_PLATFORM = `-- 005_qa_leads_go_to_qa_platform.sql — a QA signup never lands in the real Faya funnel.\n--\n-- The V2-native e2e (qa+e2e-<ts>@fayalabs.com) created leads in the REAL \\`controlgroup\\` CRM because\n-- plg_getting_started_lead always targeted crm_tenant_slug. Now, when the tenant's owner/contact\n-- e-mail matches qa_email_pattern (ILIKE), the lead goes to qa_crm_tenant_slug instead. When that\n-- tenant does not exist the lead is NOT created and the handler result (event_deliveries.result)\n-- says why — it never falls back to the real CRM. Any other e-mail: unchanged (004 behaviour).\n--\n-- Also restores 003's guarded plg_getting_started_on_trial_started: 004 re-created the unguarded 001\n-- form by accident (any tenant member could emit trial_started and mint a lead without a trial).\n\n-- ── config: where QA leads go ─────────────────────────────────────────────────\nALTER TABLE public.plg_getting_started_config\n ADD COLUMN IF NOT EXISTS qa_email_pattern text NOT NULL DEFAULT 'qa+%@fayalabs.com',\n ADD COLUMN IF NOT EXISTS qa_crm_tenant_slug text NOT NULL DEFAULT 'controlgroup-qa';\n-- 003 narrowed authenticated to (id, trial_days, setup_window_days); the new columns are read by\n-- DEFINER functions only, so no column grant is added here.\n\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_crm_slug text;\n v_qa_pat text;\n v_qa_slug text;\n v_qa boolean := false;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug, nullif(btrim(c.qa_email_pattern), ''), nullif(btrim(c.qa_crm_tenant_slug), '')\n INTO v_crm_slug, v_qa_pat, v_qa_slug\n FROM public.plg_getting_started_config c;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n\n -- QA contact → the QA platform's CRM, never the real one (not even as a fallback).\n v_qa := v_qa_pat IS NOT NULL AND v_email IS NOT NULL AND v_email ILIKE v_qa_pat;\n IF v_qa THEN\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_qa_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN\n RETURN jsonb_build_object('skipped', 'qa crm tenant not found', 'qa', true,\n 'qa_crm_tenant_slug', v_qa_slug, 'tag', p_tag);\n END IF;\n ELSE\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_crm_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n END IF;\n\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(CASE WHEN length(btrim(v_name)) < 2 THEN rpad(btrim(v_name), 2, '.') ELSE btrim(v_name) END, 120), v_phone, v_email,\n 'getting-started', 'Teste grátis — ' || coalesce(v_slug, ''),\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag)\n || CASE WHEN v_qa THEN jsonb_build_object('qa', true, 'crm_tenant_slug', v_qa_slug) ELSE '{}'::jsonb END;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- ── 003's guard, back (004 regressed it to the 001 form) ──────────────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.trial_started_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'trial_started') THEN\n RETURN jsonb_build_object('skipped', 'trial_started was not written by getting_started_start_trial');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;\n`\n\nexport const MIGRATION_006_THE_LEAD_IS_BORN_AT_SIGNUP = `-- 006_the_lead_is_born_at_signup.sql — the trial request becomes a lead when the workspace is born,\n-- not only when \"Iniciar teste\" is clicked (TP-08).\n--\n-- Before: a V2 house reached the Faya CRM only through getting_started.trial_started (005). A house\n-- that signed up and never started the trial was invisible to sales.\n--\n-- Now:\n-- * When the getting-started binding becomes active (app.tenant_plugins: create_tenant through the\n-- food vertical defaults, or ensureDefaultModules / tenant_plugin_set right after), a DEFINER\n-- trigger leaves its mark on the timeline (kind 'signed_up', once per tenant, ever) and files\n-- getting_started.signed_up. The trigger runs inside the signup transaction and never fails it.\n-- It does not build the lead there: the owner's membership may not exist yet (the binding is\n-- written by the tenants AFTER INSERT trigger, before create_tenant inserts the membership).\n-- * The handler (async, after commit) accepts only what the trigger wrote — same rule as 003:\n-- plg_emit_event is open to any member, and a hand-made signed_up must not mint a lead — and\n-- creates the lead with origem \"Cadastro V2\". create_public_lead puts it on the first open\n-- stage of the CRM's default funnel (crm_lead_to_board).\n-- * When the trial starts, plg_getting_started_lead finds the SAME lead (create_public_lead dedupes\n-- by the owner's e-mail) and moves its deal FORWARD to the stage named by\n-- config.lead_stage_patterns->>'trial_started' (ILIKE, default '%teste%') — never backwards,\n-- never out of won/lost. No matching stage: nothing moves and the result says why.\n-- * QA rule of 005 kept: qa+…@fayalabs.com → controlgroup-qa, never controlgroup.\n-- * 003's guarded plg_getting_started_on_trial_started is NOT re-created here (004 lost it once by\n-- re-creating it; 005 restored it). This file only touches the lead function it calls.\n--\n-- Only who is signing up gets a lead: free plan, inside the trial window, not converted, trial not\n-- started, workspace created in the last 48 hours. A binding switched on for an old or paying\n-- tenant (an operator, a backfill) is not a signup.\n\n-- ── config: where each journey tag moves the deal ─────────────────────────────\nALTER TABLE public.plg_getting_started_config\n ADD COLUMN IF NOT EXISTS lead_stage_patterns jsonb NOT NULL DEFAULT '{\"trial_started\": \"%teste%\"}'::jsonb;\n-- Read by DEFINER functions only (003 narrowed the column grant of authenticated).\n\n-- ── the lead: origem per tag, and the stage it moves to ───────────────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_crm uuid;\n v_slug text;\n v_crm_slug text;\n v_qa_pat text;\n v_qa_slug text;\n v_qa boolean := false;\n v_name text;\n v_email text;\n v_phone text;\n v_lead uuid;\n v_j record;\n v_patterns jsonb;\n v_pattern text;\n v_origin text;\n v_deal record;\n v_target record;\n v_stage jsonb;\nBEGIN\n IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN\n RETURN jsonb_build_object('skipped', 'plugin-crm absent');\n END IF;\n SELECT c.crm_tenant_slug, nullif(btrim(c.qa_email_pattern), ''), nullif(btrim(c.qa_crm_tenant_slug), ''),\n coalesce(c.lead_stage_patterns, '{}'::jsonb)\n INTO v_crm_slug, v_qa_pat, v_qa_slug, v_patterns\n FROM public.plg_getting_started_config c;\n\n SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;\n IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;\n\n SELECT u.email, pe.phone INTO v_email, v_phone\n FROM app.memberships m\n JOIN auth.users u ON u.id = m.user_id\n LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)\n WHERE m.tenant_id = p_tenant AND m.active\n ORDER BY m.created_at\n LIMIT 1;\n\n -- QA contact → the QA platform's CRM, never the real one (not even as a fallback). 005.\n v_qa := v_qa_pat IS NOT NULL AND v_email IS NOT NULL AND v_email ILIKE v_qa_pat;\n IF v_qa THEN\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_qa_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN\n RETURN jsonb_build_object('skipped', 'qa crm tenant not found', 'qa', true,\n 'qa_crm_tenant_slug', v_qa_slug, 'tag', p_tag);\n END IF;\n ELSE\n SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_crm_slug;\n IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;\n END IF;\n\n -- create_public_lead raises on a phone outside 8–15 digits; a bad number is dropped, not fatal.\n v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\\\D', '', 'g'), '');\n IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;\n IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;\n\n SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;\n\n -- The origem (sourceName) is written when the lead is BORN; a later tag on the same person goes\n -- to its submissions. The signup is the birth now, so it names the origem.\n v_origin := CASE WHEN p_tag = 'cadastro_v2' THEN 'Cadastro V2' ELSE 'Teste grátis — ' || coalesce(v_slug, '') END;\n\n -- Dedupe by e-mail/phone is create_public_lead's own: the signup lead and the trial lead are one person.\n SELECT l.lead_id INTO v_lead FROM public.create_public_lead(\n v_crm, left(CASE WHEN length(btrim(v_name)) < 2 THEN rpad(btrim(v_name), 2, '.') ELSE btrim(v_name) END, 120), v_phone, v_email,\n 'getting-started', v_origin,\n jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,\n 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)\n || coalesce(p_extra, '{}'::jsonb),\n NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;\n\n -- ── the deal moves forward to the stage this tag names ─────────────────────\n v_pattern := nullif(btrim(coalesce(v_patterns ->> p_tag, '')), '');\n IF v_lead IS NOT NULL AND v_pattern IS NOT NULL AND to_regclass('public.plg_crm_deals') IS NOT NULL THEN\n -- The card is born by crm_lead_to_board; a lead without one (trigger failed, hand-made) gets it now.\n IF NOT EXISTS (SELECT 1 FROM public.plg_crm_deals d WHERE d.lead_id = v_lead)\n AND to_regprocedure('public.crm_place_lead_on_board(uuid)') IS NOT NULL THEN\n PERFORM public.crm_place_lead_on_board(v_lead);\n END IF;\n SELECT d.id, d.status, d.pipeline_id, d.stage_id, s.name AS stage_name, s.\"order\" AS stage_order,\n coalesce(s.is_won, false) OR coalesce(s.is_lost, false) AS closed\n INTO v_deal\n FROM public.plg_crm_deals d\n LEFT JOIN public.plg_crm_pipeline_stages s ON s.id = d.stage_id\n WHERE d.lead_id = v_lead AND d.tenant_id = v_crm\n LIMIT 1;\n IF v_deal.id IS NULL THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'no deal for the lead');\n ELSIF v_deal.status <> 'open' OR v_deal.closed THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'deal is closed', 'deal_id', v_deal.id);\n ELSE\n SELECT s.id, s.name, s.\"order\", s.probability INTO v_target\n FROM public.plg_crm_pipeline_stages s\n WHERE s.pipeline_id = v_deal.pipeline_id AND NOT coalesce(s.is_won, false) AND NOT coalesce(s.is_lost, false)\n AND s.name ILIKE v_pattern\n ORDER BY s.\"order\", s.created_at\n LIMIT 1;\n IF v_target.id IS NULL THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'no stage matches', 'pattern', v_pattern, 'deal_id', v_deal.id);\n ELSIF v_deal.stage_id = v_target.id THEN\n v_stage := jsonb_build_object('moved', false, 'reason', 'already there', 'stage', v_target.name, 'deal_id', v_deal.id);\n ELSIF v_deal.stage_order IS NOT NULL AND v_deal.stage_order > v_target.\"order\" THEN\n -- Sales already moved it further (proposal, negotiation): the journey never pulls it back.\n v_stage := jsonb_build_object('moved', false, 'reason', 'deal is ahead', 'stage', v_deal.stage_name, 'deal_id', v_deal.id);\n ELSE\n UPDATE public.plg_crm_deals\n SET stage_id = v_target.id, probability = coalesce(v_target.probability, probability)\n WHERE id = v_deal.id;\n v_stage := jsonb_build_object('moved', true, 'from', v_deal.stage_name, 'to', v_target.name, 'deal_id', v_deal.id);\n END IF;\n END IF;\n END IF;\n\n RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag)\n || CASE WHEN v_stage IS NOT NULL THEN jsonb_build_object('stage', v_stage) ELSE '{}'::jsonb END\n || CASE WHEN v_qa THEN jsonb_build_object('qa', true, 'crm_tenant_slug', v_qa_slug) ELSE '{}'::jsonb END;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;\n\n-- ── the mark: the binding became active for a house that is signing up ────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_binding()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_t record;\n v_j record;\n v_payload jsonb;\nBEGIN\n IF NEW.plugin_id <> 'getting-started' OR NEW.facet <> '' OR NEW.status <> 'active' THEN RETURN NULL; END IF;\n IF TG_OP = 'UPDATE' AND OLD.status = 'active' THEN RETURN NULL; END IF;\n -- Never fails the signup: an account created without its lead is recoverable, a lost signup is not.\n BEGIN\n -- Once per tenant, ever: switching the module off and on is not a second signup.\n IF EXISTS (SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = NEW.tenant_id AND e.kind = 'signed_up') THEN\n RETURN NULL;\n END IF;\n SELECT t.plan, t.trial_ends_at, t.created_at INTO v_t FROM public.tenants t WHERE t.id = NEW.tenant_id;\n IF NOT FOUND\n OR coalesce(nullif(btrim(v_t.plan), ''), 'free') <> 'free'\n OR v_t.trial_ends_at IS NULL\n OR v_t.created_at < now() - interval '48 hours' THEN\n RETURN NULL;\n END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j\n FROM public.plg_getting_started_journeys j WHERE j.tenant_id = NEW.tenant_id;\n IF v_j.trial_started_at IS NOT NULL OR v_j.converted_at IS NOT NULL THEN\n RETURN NULL;\n END IF;\n\n v_payload := jsonb_build_object('source', 'cadastro_v2', 'binding_source', NEW.source);\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (NEW.tenant_id, 'signed_up', auth.uid(), v_payload)\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n -- plg_emit_event refuses a caller who is not yet a member, and inside create_tenant the\n -- membership comes after the binding: file the fact directly (002's helper).\n PERFORM public.plg_getting_started_platform_emit(NEW.tenant_id, 'getting_started.signed_up', v_payload);\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING '[getting-started] signed_up not recorded for tenant %: %', NEW.tenant_id, SQLERRM;\n END;\n RETURN NULL;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_binding() FROM PUBLIC, anon, authenticated;\n\nDROP TRIGGER IF EXISTS plg_getting_started_signed_up ON app.tenant_plugins;\nCREATE TRIGGER plg_getting_started_signed_up\n AFTER INSERT OR UPDATE OF status ON app.tenant_plugins\n FOR EACH ROW\n WHEN (NEW.plugin_id = 'getting-started' AND NEW.status = 'active')\n EXECUTE FUNCTION public.plg_getting_started_on_binding();\n\n-- ── the handler accepts only what the trigger wrote (003's rule) ──────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_signed_up(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'signed_up') THEN\n RETURN jsonb_build_object('skipped', 'signed_up was not written by the getting-started binding');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'cadastro_v2', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_signed_up(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_signed_up(jsonb) TO service_role;\n\nSELECT public.register_event_subscription('getting-started', 'getting_started.signed_up', 'plg_getting_started_on_signed_up',\n 'Faya lead when the workspace is created (cadastro V2)', 5);\n`\n\nexport const MIGRATION_007_THE_TRIAL_CLOCK_STARTS_AT_THE_CLICK = `-- 007_the_trial_clock_starts_at_the_click.sql — the trial is 14 days counted from\n-- \"Iniciar teste\"; nothing counts before that. Depends on 000, 003 and 006.\n--\n-- * v_getting_started_journey: \\`expired\\` only after a trial that STARTED. A date\n-- left by the old signup clock (db 044) on a tenant still in setup no longer\n-- reads \"Seu teste terminou\" above \"Para iniciar o teste\". No setup deadline.\n-- * getting_started_start_trial: a tenant with no date can start — that is now\n-- every new tenant. Still once per tenant, never for converted or paying.\n-- * plg_getting_started_on_binding: a tenant born with this plugin drops the\n-- date the spine DEFAULT stamped. Apps without getting-started keep that\n-- DEFAULT (they have no \"Iniciar teste\" to start a clock).\n--\n-- Existing tenants still in setup keep the old date until the one-off cleanup in\n-- docs/checkpoints/FAY-1763-BACKEND-HANDOFF.md runs (it changes live accounts).\n\n-- ── the phase ─────────────────────────────────────────────────────────────────\nCREATE OR REPLACE VIEW public.v_getting_started_journey WITH (security_invoker = true) AS\nSELECT t.id AS tenant_id,\n j.ready_at, j.trial_started_at, j.converted_at, j.wants_contract_at, j.source, j.utm,\n coalesce(j.trial_days, c.trial_days) AS trial_days,\n t.trial_ends_at,\n NULL::timestamptz AS setup_deadline_at,\n CASE\n WHEN j.converted_at IS NOT NULL THEN 'converted'\n WHEN j.trial_started_at IS NULL THEN 'setup'\n WHEN t.trial_ends_at IS NOT NULL AND t.trial_ends_at <= now() THEN 'expired'\n ELSE 'trial'\n END AS phase\n FROM public.tenants t\n CROSS JOIN public.plg_getting_started_config c\n LEFT JOIN public.plg_getting_started_journeys j ON j.tenant_id = t.id;\nGRANT SELECT ON public.v_getting_started_journey TO authenticated;\n\n-- ── the start: no date is the normal case now ─────────────────────────────────\nCREATE OR REPLACE FUNCTION public.getting_started_start_trial(p_tenant uuid DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());\n v_days integer;\n v_min jsonb;\n v_t record;\n v_j record;\n v_started timestamptz;\n v_ends timestamptz;\nBEGIN\n -- Same gate as tenant_setting_set: a session, the tenant in scope, settings.manage.\n IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'getting_started_start_trial: no session or tenant out of scope' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('settings.manage') THEN\n RAISE EXCEPTION 'getting_started_start_trial: settings.manage is required' USING ERRCODE = '42501';\n END IF;\n\n -- The lock: a second concurrent call waits here and then sees the start.\n SELECT t.id, t.trial_ends_at, t.plan INTO v_t FROM public.tenants t WHERE t.id = v_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'getting_started_start_trial: tenant not found' USING ERRCODE = 'P0002';\n END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j\n FROM public.plg_getting_started_journeys j WHERE j.tenant_id = v_tenant FOR UPDATE;\n\n -- Idempotent: a second call returns the state and leaves the date alone.\n IF v_j.trial_started_at IS NOT NULL THEN\n RETURN jsonb_build_object('ok', true, 'already_started', true, 'trial_started_at', v_j.trial_started_at,\n 'trial_ends_at', v_t.trial_ends_at);\n END IF;\n\n v_min := public.getting_started_minimums(v_tenant);\n\n -- Who is already converted, or already paying, is not offered a clock that\n -- would expire on them. A NULL trial_ends_at no longer means \"no trial\": since\n -- 007 every new tenant is born without a clock and gets it here.\n IF v_j.converted_at IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'already_converted', 'minimums', v_min);\n END IF;\n IF coalesce(nullif(btrim(v_t.plan), ''), 'free') <> 'free' THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'already_paid', 'minimums', v_min, 'plan', v_t.plan);\n END IF;\n\n -- Minimums are checked here, not in the button.\n IF NOT coalesce((v_min ->> 'ready')::boolean, false) THEN\n RETURN jsonb_build_object('ok', false, 'reason', 'minimums_missing', 'minimums', v_min);\n END IF;\n\n SELECT c.trial_days INTO v_days FROM public.plg_getting_started_config c;\n v_days := coalesce(v_days, 14);\n v_started := now();\n v_ends := v_started + make_interval(days => v_days);\n\n INSERT INTO public.plg_getting_started_journeys (tenant_id, ready_at, trial_started_at, trial_days)\n VALUES (v_tenant, v_started, v_started, v_days)\n ON CONFLICT (tenant_id) DO UPDATE\n SET ready_at = coalesce(public.plg_getting_started_journeys.ready_at, EXCLUDED.ready_at),\n trial_started_at = EXCLUDED.trial_started_at,\n trial_days = EXCLUDED.trial_days,\n updated_at = now();\n\n -- The real clock. Passes tenants_trial_is_readonly (db 221): this DEFINER\n -- function runs as its owner, which the gate lets through.\n UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = v_tenant;\n\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (v_tenant, 'trial_started', auth.uid(), jsonb_build_object('trial_days', v_days, 'ends_at', v_ends))\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n\n PERFORM public.plg_emit_event('getting_started.trial_started',\n jsonb_build_object('trial_days', v_days, 'trial_ends_at', v_ends), 'tenant', v_tenant::text, v_tenant);\n\n RETURN jsonb_build_object('ok', true, 'trial_started_at', v_started, 'trial_ends_at', v_ends, 'trial_days', v_days);\nEND $$;\nREVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;\n\n-- ── the platform config: the client reads only what it needs ─────────────────\n-- The view (000) reads trial_days; the setup window is documentation of the\n-- spine default. crm_tenant_slug and reminder_hour_utc are read inside the\n-- DEFINER functions only. The policy stays (USING true); the GRANT narrows.\nREVOKE SELECT ON public.plg_getting_started_config FROM authenticated;\nGRANT SELECT (id, trial_days, setup_window_days) ON public.plg_getting_started_config TO authenticated;\n\n-- ── the handlers accept only what the RPCs wrote ──────────────────────────────\n-- plg_emit_event is open to any member of the tenant; the journey row and the\n-- timeline are written by DEFINER RPCs only (no write policy). So a lead is\n-- created only when the mark the RPC leaves is there.\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.trial_started_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'trial_started') THEN\n RETURN jsonb_build_object('skipped', 'trial_started was not written by getting_started_start_trial');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_wants_contract(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_journeys j\n WHERE j.tenant_id = v_tenant AND j.wants_contract_at IS NOT NULL)\n OR NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'wants_contract'\n AND e.created_at >= now() - interval '2 days') THEN\n RETURN jsonb_build_object('skipped', 'wants_contract was not written by getting_started_request_contract');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'quer_contratar', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) TO service_role;\nREVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;\n\n-- ── the birth: drop the signup date ───────────────────────────────────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_binding()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_t record;\n v_j record;\n v_payload jsonb;\nBEGIN\n IF NEW.plugin_id <> 'getting-started' OR NEW.facet <> '' OR NEW.status <> 'active' THEN RETURN NULL; END IF;\n IF TG_OP = 'UPDATE' AND OLD.status = 'active' THEN RETURN NULL; END IF;\n -- Never fails the signup: an account created without its lead is recoverable, a lost signup is not.\n BEGIN\n -- Once per tenant, ever: switching the module off and on is not a second signup.\n IF EXISTS (SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = NEW.tenant_id AND e.kind = 'signed_up') THEN\n RETURN NULL;\n END IF;\n SELECT t.plan, t.trial_ends_at, t.created_at INTO v_t FROM public.tenants t WHERE t.id = NEW.tenant_id;\n IF NOT FOUND\n OR coalesce(nullif(btrim(v_t.plan), ''), 'free') <> 'free'\n OR v_t.trial_ends_at IS NULL\n OR v_t.created_at < now() - interval '48 hours' THEN\n RETURN NULL;\n END IF;\n SELECT j.trial_started_at, j.converted_at INTO v_j\n FROM public.plg_getting_started_journeys j WHERE j.tenant_id = NEW.tenant_id;\n IF v_j.trial_started_at IS NOT NULL OR v_j.converted_at IS NOT NULL THEN\n RETURN NULL;\n END IF;\n\n v_payload := jsonb_build_object('source', 'cadastro_v2', 'binding_source', NEW.source);\n INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)\n VALUES (NEW.tenant_id, 'signed_up', auth.uid(), v_payload)\n ON CONFLICT (tenant_id, kind, day) DO NOTHING;\n -- plg_emit_event refuses a caller who is not yet a member, and inside create_tenant the\n -- membership comes after the binding: file the fact directly (002's helper).\n PERFORM public.plg_getting_started_platform_emit(NEW.tenant_id, 'getting_started.signed_up', v_payload);\n -- The spine DEFAULT (db 044) stamped a 30-day date at signup; the trial is 14\n -- days from \"Iniciar teste\", so this tenant starts with no clock at all.\n UPDATE public.tenants SET trial_ends_at = NULL WHERE id = NEW.tenant_id;\n EXCEPTION WHEN OTHERS THEN\n RAISE WARNING '[getting-started] signed_up not recorded for tenant %: %', NEW.tenant_id, SQLERRM;\n END;\n RETURN NULL;\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_binding() FROM PUBLIC, anon, authenticated;\n\nDROP TRIGGER IF EXISTS plg_getting_started_signed_up ON app.tenant_plugins;\nCREATE TRIGGER plg_getting_started_signed_up\n AFTER INSERT OR UPDATE OF status ON app.tenant_plugins\n FOR EACH ROW\n WHEN (NEW.plugin_id = 'getting-started' AND NEW.status = 'active')\n EXECUTE FUNCTION public.plg_getting_started_on_binding();\n\n-- ── the handler accepts only what the trigger wrote (003's rule) ──────────────\nCREATE OR REPLACE FUNCTION public.plg_getting_started_on_signed_up(p_event jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\nBEGIN\n IF v_tenant IS NULL THEN\n RETURN jsonb_build_object('skipped', 'no tenant');\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_getting_started_events e\n WHERE e.tenant_id = v_tenant AND e.kind = 'signed_up') THEN\n RETURN jsonb_build_object('skipped', 'signed_up was not written by the getting-started binding');\n END IF;\n RETURN public.plg_getting_started_lead(v_tenant, 'cadastro_v2', coalesce(p_event -> 'payload', '{}'::jsonb));\nEND $$;\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_signed_up(jsonb) FROM PUBLIC, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_getting_started_on_signed_up(jsonb) TO service_role;\n\nSELECT public.register_event_subscription('getting-started', 'getting_started.signed_up', 'plg_getting_started_on_signed_up',\n 'Faya lead when the workspace is created (cadastro V2)', 5);\nREVOKE ALL ON FUNCTION public.plg_getting_started_on_binding() FROM PUBLIC, anon, authenticated;\n`\n\nexport const MIGRATIONS: Array<{ id: string; sql: string }> = [\n { id: \"000_journey\", sql: MIGRATION_000_JOURNEY },\n { id: \"001_trial_clock\", sql: MIGRATION_001_TRIAL_CLOCK },\n { id: \"002_platform_operator\", sql: MIGRATION_002_PLATFORM_OPERATOR },\n { id: \"003_the_clock_starts_once_and_only_for_who_may\", sql: MIGRATION_003_THE_CLOCK_STARTS_ONCE_AND_ONLY_FOR_WHO_MAY },\n { id: \"004_lead_keeps_the_name\", sql: MIGRATION_004_LEAD_KEEPS_THE_NAME },\n { id: \"005_qa_leads_go_to_qa_platform\", sql: MIGRATION_005_QA_LEADS_GO_TO_QA_PLATFORM },\n { id: \"006_the_lead_is_born_at_signup\", sql: MIGRATION_006_THE_LEAD_IS_BORN_AT_SIGNUP },\n { id: \"007_the_trial_clock_starts_at_the_click\", sql: MIGRATION_007_THE_TRIAL_CLOCK_STARTS_AT_THE_CLICK },\n]\n"]}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "status": "beta",
5
5
  "dependencies": []
6
6
  },
7
- "version": "0.2.0-canary.20261028153856",
7
+ "version": "0.2.0",
8
8
  "description": "Fayz SDK — getting-started plugin: the store-live checklist, the trial clock and the start-trial CTA",
9
9
  "type": "module",
10
10
  "sideEffects": false,
@@ -43,9 +43,9 @@
43
43
  }
44
44
  },
45
45
  "dependencies": {
46
- "@fayz-ai/admin": "^0.76.0-canary.20261028153856",
47
- "@fayz-ai/core": "^0.76.0-canary.20261028153856",
48
- "@fayz-ai/ui": "^0.76.0-canary.20261028153856"
46
+ "@fayz-ai/admin": "^0.77.0",
47
+ "@fayz-ai/core": "^0.77.0",
48
+ "@fayz-ai/ui": "^0.77.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/react": "^18.3.0",