@fayz-ai/plugin-getting-started 0.0.0-stage → 0.2.0-canary.20261027164140

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.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/chunk-QAYRR3A4.js +2964 -0
  3. package/dist/chunk-QAYRR3A4.js.map +1 -0
  4. package/dist/components/ProgressRing.d.ts +16 -0
  5. package/dist/components/ProgressRing.d.ts.map +1 -0
  6. package/dist/components/StartTrialAction.d.ts +70 -0
  7. package/dist/components/StartTrialAction.d.ts.map +1 -0
  8. package/dist/components/StartTrialCta.d.ts +12 -0
  9. package/dist/components/StartTrialCta.d.ts.map +1 -0
  10. package/dist/components/StoreLiveCard.d.ts +21 -0
  11. package/dist/components/StoreLiveCard.d.ts.map +1 -0
  12. package/dist/components/TrialEndBanner.d.ts +12 -0
  13. package/dist/components/TrialEndBanner.d.ts.map +1 -0
  14. package/dist/config.d.ts +82 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/context.d.ts +33 -0
  17. package/dist/context.d.ts.map +1 -0
  18. package/dist/data/index.d.ts +5 -0
  19. package/dist/data/index.d.ts.map +1 -0
  20. package/dist/data/mock.d.ts +27 -0
  21. package/dist/data/mock.d.ts.map +1 -0
  22. package/dist/data/supabase.d.ts +10 -0
  23. package/dist/data/supabase.d.ts.map +1 -0
  24. package/dist/data/tables.d.ts +15 -0
  25. package/dist/data/tables.d.ts.map +1 -0
  26. package/dist/data/types.d.ts +24 -0
  27. package/dist/data/types.d.ts.map +1 -0
  28. package/dist/host.d.ts +93 -0
  29. package/dist/host.d.ts.map +1 -0
  30. package/dist/host.js +78 -0
  31. package/dist/host.js.map +1 -0
  32. package/dist/index.d.ts +26 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +138 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/lib/agent-tools.d.ts +143 -0
  37. package/dist/lib/agent-tools.d.ts.map +1 -0
  38. package/dist/lib/journey-steps.d.ts +152 -0
  39. package/dist/lib/journey-steps.d.ts.map +1 -0
  40. package/dist/lib/program.d.ts +37 -0
  41. package/dist/lib/program.d.ts.map +1 -0
  42. package/dist/lib/store-profile.d.ts +75 -0
  43. package/dist/lib/store-profile.d.ts.map +1 -0
  44. package/dist/lib/store-settings.d.ts +70 -0
  45. package/dist/lib/store-settings.d.ts.map +1 -0
  46. package/dist/lib/tour.d.ts +86 -0
  47. package/dist/lib/tour.d.ts.map +1 -0
  48. package/dist/lib/trial-clock.d.ts +14 -0
  49. package/dist/lib/trial-clock.d.ts.map +1 -0
  50. package/dist/lib/trial-source-bridge.d.ts +17 -0
  51. package/dist/lib/trial-source-bridge.d.ts.map +1 -0
  52. package/dist/locales/en.d.ts +2 -0
  53. package/dist/locales/en.d.ts.map +1 -0
  54. package/dist/locales/index.d.ts +2 -0
  55. package/dist/locales/index.d.ts.map +1 -0
  56. package/dist/locales/pt-BR.d.ts +2 -0
  57. package/dist/locales/pt-BR.d.ts.map +1 -0
  58. package/dist/migrations/index.d.ts +10 -0
  59. package/dist/migrations/index.d.ts.map +1 -0
  60. package/dist/migrations/main.browser.js +11 -0
  61. package/dist/migrations/main.browser.js.map +1 -0
  62. package/dist/migrations/main.js +992 -0
  63. package/dist/migrations/main.js.map +1 -0
  64. package/dist/settings/TrialSettingsTab.d.ts +23 -0
  65. package/dist/settings/TrialSettingsTab.d.ts.map +1 -0
  66. package/dist/store.d.ts +23 -0
  67. package/dist/store.d.ts.map +1 -0
  68. package/dist/types.d.ts +80 -0
  69. package/dist/types.d.ts.map +1 -0
  70. package/package.json +74 -3
  71. package/README.md +0 -3
@@ -0,0 +1,992 @@
1
+ // src/migrations/index.ts
2
+ var MIGRATION_000_JOURNEY = `-- 000_journey.sql \u2014 the getting-started plugin keeps the journey; tenants.trial_ends_at stays the end of the trial.
3
+
4
+ -- \u2500\u2500 platform config (one row; only service_role/postgres write) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5
+ CREATE TABLE IF NOT EXISTS public.plg_getting_started_config (
6
+ id boolean PRIMARY KEY DEFAULT true CHECK (id),
7
+ trial_days integer NOT NULL DEFAULT 14 CHECK (trial_days BETWEEN 1 AND 90),
8
+ -- Documents the spine DEFAULT (db 044); never changes it.
9
+ setup_window_days integer NOT NULL DEFAULT 30,
10
+ crm_tenant_slug text NOT NULL DEFAULT 'controlgroup',
11
+ reminder_hour_utc integer NOT NULL DEFAULT 13 CHECK (reminder_hour_utc BETWEEN 0 AND 23),
12
+ updated_at timestamptz NOT NULL DEFAULT now()
13
+ );
14
+ INSERT INTO public.plg_getting_started_config (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
15
+ ALTER TABLE public.plg_getting_started_config ENABLE ROW LEVEL SECURITY;
16
+ REVOKE ALL ON public.plg_getting_started_config FROM PUBLIC, anon, authenticated;
17
+ -- Read by the view and the RPCs; no write policy on purpose.
18
+ GRANT SELECT ON public.plg_getting_started_config TO authenticated;
19
+ DROP POLICY IF EXISTS plg_getting_started_config_read ON public.plg_getting_started_config;
20
+ CREATE POLICY plg_getting_started_config_read ON public.plg_getting_started_config
21
+ FOR SELECT TO authenticated USING (true);
22
+
23
+ -- \u2500\u2500 the journey: one row per tenant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
24
+ CREATE TABLE IF NOT EXISTS public.plg_getting_started_journeys (
25
+ tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
26
+ ready_at timestamptz,
27
+ trial_started_at timestamptz,
28
+ trial_days integer,
29
+ converted_at timestamptz,
30
+ wants_contract_at timestamptz,
31
+ source text,
32
+ utm jsonb NOT NULL DEFAULT '{}'::jsonb,
33
+ answers_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
34
+ created_at timestamptz NOT NULL DEFAULT now(),
35
+ updated_at timestamptz NOT NULL DEFAULT now()
36
+ );
37
+ ALTER TABLE public.plg_getting_started_journeys ENABLE ROW LEVEL SECURITY;
38
+ ALTER TABLE public.plg_getting_started_journeys FORCE ROW LEVEL SECURITY;
39
+ -- Writes go through the RPCs only.
40
+ GRANT SELECT ON public.plg_getting_started_journeys TO authenticated;
41
+ DROP POLICY IF EXISTS plg_getting_started_journeys_select ON public.plg_getting_started_journeys;
42
+ CREATE POLICY plg_getting_started_journeys_select ON public.plg_getting_started_journeys
43
+ FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
44
+
45
+ -- \u2500\u2500 timeline + idempotency lock (one row per tenant, kind and day) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46
+ CREATE TABLE IF NOT EXISTS public.plg_getting_started_events (
47
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
48
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
49
+ kind text NOT NULL CHECK (kind ~ '^[a-z][a-z0-9_]*$'),
50
+ day date NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')::date,
51
+ channel text,
52
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
53
+ actor_id uuid,
54
+ created_at timestamptz NOT NULL DEFAULT now()
55
+ );
56
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_getting_started_events_once_a_day
57
+ ON public.plg_getting_started_events (tenant_id, kind, day);
58
+ CREATE INDEX IF NOT EXISTS plg_getting_started_events_tenant
59
+ ON public.plg_getting_started_events (tenant_id, created_at DESC);
60
+ ALTER TABLE public.plg_getting_started_events ENABLE ROW LEVEL SECURITY;
61
+ ALTER TABLE public.plg_getting_started_events FORCE ROW LEVEL SECURITY;
62
+ GRANT SELECT ON public.plg_getting_started_events TO authenticated;
63
+ DROP POLICY IF EXISTS plg_getting_started_events_select ON public.plg_getting_started_events;
64
+ CREATE POLICY plg_getting_started_events_select ON public.plg_getting_started_events
65
+ FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
66
+
67
+ -- \u2500\u2500 wizard keys this plugin owns (tech design, decision 5) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
68
+ SELECT app.seed_setting_keys($$[
69
+ { "key": "store.hours", "type": "object", "default": {}, "reader": "plugin:getting-started",
70
+ "description": "Opening hours per weekday: {\\"mon\\":[{\\"open\\":\\"11:00\\",\\"close\\":\\"23:00\\"}],\u2026}. Empty = not informed." },
71
+ { "key": "store.payment_methods", "type": "array", "default": [], "reader": "plugin:getting-started",
72
+ "description": "Payment methods accepted at the counter and on delivery: cash, pix, credit, debit, voucher." },
73
+ { "key": "store.welcome_article", "type": "string", "default": "a", "reader": "plugin:getting-started",
74
+ "description": "Article before the store name in greetings: a | o." }
75
+ ]$$::jsonb);
76
+
77
+ -- \u2500\u2500 the phase, derived (decision 1 and \xA73) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
78
+ -- security_invoker: the RLS of tenants decides which rows are visible (same pattern as v_team_members, db 004).
79
+ CREATE OR REPLACE VIEW public.v_getting_started_journey WITH (security_invoker = true) AS
80
+ SELECT t.id AS tenant_id,
81
+ j.ready_at, j.trial_started_at, j.converted_at, j.wants_contract_at, j.source, j.utm,
82
+ coalesce(j.trial_days, c.trial_days) AS trial_days,
83
+ t.trial_ends_at,
84
+ CASE WHEN j.trial_started_at IS NULL THEN t.trial_ends_at END AS setup_deadline_at,
85
+ CASE
86
+ WHEN j.converted_at IS NOT NULL THEN 'converted'
87
+ WHEN t.trial_ends_at IS NOT NULL AND t.trial_ends_at <= now() THEN 'expired'
88
+ WHEN j.trial_started_at IS NOT NULL THEN 'trial'
89
+ ELSE 'setup'
90
+ END AS phase
91
+ FROM public.tenants t
92
+ CROSS JOIN public.plg_getting_started_config c
93
+ LEFT JOIN public.plg_getting_started_journeys j ON j.tenant_id = t.id;
94
+ GRANT SELECT ON public.v_getting_started_journey TO authenticated;
95
+
96
+ -- \u2500\u2500 the minimums, one function (used by the start RPC and by the read) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
97
+ CREATE OR REPLACE FUNCTION public.getting_started_minimums(p_tenant uuid)
98
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$
99
+ DECLARE
100
+ v_menu boolean := false;
101
+ v_hours boolean := false;
102
+ v_order boolean := false;
103
+ v_settings jsonb;
104
+ BEGIN
105
+ SELECT t.settings INTO v_settings FROM public.tenants t WHERE t.id = p_tenant;
106
+ v_hours := jsonb_typeof(v_settings #> '{store,hours}') = 'object'
107
+ AND (v_settings #> '{store,hours}') <> '{}'::jsonb;
108
+ -- A pool without plugin-menu must not break the read.
109
+ IF to_regclass('public.plg_menu_items') IS NOT NULL THEN
110
+ EXECUTE $q$SELECT EXISTS (
111
+ SELECT 1 FROM public.plg_menu_items mi
112
+ JOIN public.products p ON p.id = mi.product_id
113
+ WHERE mi.tenant_id = $1 AND mi.status = 'available' AND p.is_active
114
+ AND coalesce(p.metadata->>'demo','') <> 'true')$q$
115
+ INTO v_menu USING p_tenant;
116
+ END IF;
117
+ SELECT EXISTS (
118
+ SELECT 1 FROM public.orders o
119
+ WHERE o.tenant_id = p_tenant AND o.status = 'completed'
120
+ AND o.metadata @> '{"test":true}'::jsonb
121
+ ) INTO v_order;
122
+ RETURN jsonb_build_object(
123
+ 'menu', v_menu, 'hours', v_hours, 'test_order', v_order,
124
+ 'ready', v_menu AND v_hours AND v_order
125
+ );
126
+ END $$;
127
+ REVOKE ALL ON FUNCTION public.getting_started_minimums(uuid) FROM PUBLIC;
128
+ GRANT EXECUTE ON FUNCTION public.getting_started_minimums(uuid) TO authenticated, service_role;
129
+
130
+ -- \u2500\u2500 the read for the client \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
131
+ CREATE OR REPLACE FUNCTION public.getting_started_journey()
132
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$
133
+ DECLARE
134
+ v_tenant uuid := app.current_tenant_id();
135
+ v_row record;
136
+ BEGIN
137
+ IF v_tenant IS NULL THEN
138
+ RAISE EXCEPTION 'getting_started_journey: no active tenant' USING ERRCODE = '42501';
139
+ END IF;
140
+ SELECT * INTO v_row FROM public.v_getting_started_journey v WHERE v.tenant_id = v_tenant;
141
+ IF NOT FOUND THEN
142
+ RETURN jsonb_build_object('tenant_id', v_tenant, 'phase', 'setup',
143
+ 'minimums', public.getting_started_minimums(v_tenant));
144
+ END IF;
145
+ RETURN to_jsonb(v_row) || jsonb_build_object('minimums', public.getting_started_minimums(v_tenant));
146
+ END $$;
147
+ REVOKE ALL ON FUNCTION public.getting_started_journey() FROM PUBLIC;
148
+ GRANT EXECUTE ON FUNCTION public.getting_started_journey() TO authenticated, service_role;
149
+ `;
150
+ var MIGRATION_001_TRIAL_CLOCK = `-- 001_trial_clock.sql \u2014 start, end, reminders and the Faya lead. Depends on 000_journey.sql.
151
+
152
+ -- \u2500\u2500 start the trial \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
153
+ CREATE OR REPLACE FUNCTION public.getting_started_start_trial(p_tenant uuid DEFAULT NULL)
154
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
155
+ DECLARE
156
+ v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());
157
+ v_days integer;
158
+ v_min jsonb;
159
+ v_started timestamptz;
160
+ v_ends timestamptz;
161
+ BEGIN
162
+ -- Same gate as tenant_setting_set: a session, the tenant in scope, settings.manage.
163
+ IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN
164
+ RAISE EXCEPTION 'getting_started_start_trial: no session or tenant out of scope' USING ERRCODE = '42501';
165
+ END IF;
166
+ IF NOT app.has_permission('settings.manage') THEN
167
+ RAISE EXCEPTION 'getting_started_start_trial: settings.manage is required' USING ERRCODE = '42501';
168
+ END IF;
169
+
170
+ -- Idempotent: a second call returns the state and leaves the date alone.
171
+ SELECT j.trial_started_at INTO v_started FROM public.plg_getting_started_journeys j WHERE j.tenant_id = v_tenant;
172
+ IF v_started IS NOT NULL THEN
173
+ RETURN jsonb_build_object('ok', true, 'already_started', true, 'trial_started_at', v_started,
174
+ 'trial_ends_at', (SELECT t.trial_ends_at FROM public.tenants t WHERE t.id = v_tenant));
175
+ END IF;
176
+
177
+ -- Minimums are checked here, not in the button.
178
+ v_min := public.getting_started_minimums(v_tenant);
179
+ IF NOT coalesce((v_min ->> 'ready')::boolean, false) THEN
180
+ RETURN jsonb_build_object('ok', false, 'reason', 'minimums_missing', 'minimums', v_min);
181
+ END IF;
182
+
183
+ SELECT c.trial_days INTO v_days FROM public.plg_getting_started_config c;
184
+ v_days := coalesce(v_days, 14);
185
+ v_started := now();
186
+ v_ends := v_started + make_interval(days => v_days);
187
+
188
+ INSERT INTO public.plg_getting_started_journeys (tenant_id, ready_at, trial_started_at, trial_days)
189
+ VALUES (v_tenant, v_started, v_started, v_days)
190
+ ON CONFLICT (tenant_id) DO UPDATE
191
+ SET ready_at = coalesce(public.plg_getting_started_journeys.ready_at, EXCLUDED.ready_at),
192
+ trial_started_at = EXCLUDED.trial_started_at,
193
+ trial_days = EXCLUDED.trial_days,
194
+ updated_at = now();
195
+
196
+ -- The real clock. Passes tenants_trial_is_readonly because SECURITY DEFINER runs as the owner (postgres).
197
+ UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = v_tenant;
198
+
199
+ INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)
200
+ VALUES (v_tenant, 'trial_started', auth.uid(), jsonb_build_object('trial_days', v_days, 'ends_at', v_ends))
201
+ ON CONFLICT (tenant_id, kind, day) DO NOTHING;
202
+
203
+ PERFORM public.plg_emit_event('getting_started.trial_started',
204
+ jsonb_build_object('trial_days', v_days, 'trial_ends_at', v_ends), 'tenant', v_tenant::text, v_tenant);
205
+
206
+ RETURN jsonb_build_object('ok', true, 'trial_started_at', v_started, 'trial_ends_at', v_ends, 'trial_days', v_days);
207
+ END $$;
208
+ REVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;
209
+ GRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;
210
+
211
+ -- Agent executor shape (p_tenant_id, p_actor_user_id, p_payload), as agent_crm_create_lead.
212
+ CREATE OR REPLACE FUNCTION public.agent_getting_started_start_trial(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb DEFAULT '{}'::jsonb)
213
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
214
+ BEGIN
215
+ IF auth.uid() IS NOT NULL AND auth.uid() IS DISTINCT FROM p_actor_user_id THEN
216
+ RAISE EXCEPTION 'agent_getting_started_start_trial: actor mismatch' USING ERRCODE = '42501';
217
+ END IF;
218
+ RETURN public.getting_started_start_trial(p_tenant_id);
219
+ END $$;
220
+ REVOKE ALL ON FUNCTION public.agent_getting_started_start_trial(uuid, uuid, jsonb) FROM PUBLIC;
221
+ GRANT EXECUTE ON FUNCTION public.agent_getting_started_start_trial(uuid, uuid, jsonb) TO authenticated, service_role;
222
+
223
+ -- \u2500\u2500 "I want to subscribe" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
224
+ CREATE OR REPLACE FUNCTION public.getting_started_request_contract(p_tenant uuid DEFAULT NULL, p_note text DEFAULT NULL)
225
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
226
+ DECLARE
227
+ v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());
228
+ v_inserted boolean;
229
+ BEGIN
230
+ IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN
231
+ RAISE EXCEPTION 'getting_started_request_contract: no session or tenant out of scope' USING ERRCODE = '42501';
232
+ END IF;
233
+ IF NOT app.has_permission('settings.manage') THEN
234
+ RAISE EXCEPTION 'getting_started_request_contract: settings.manage is required' USING ERRCODE = '42501';
235
+ END IF;
236
+
237
+ -- Rate limit: the timeline's one-per-day lock. The V1 had none.
238
+ INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)
239
+ VALUES (v_tenant, 'wants_contract', auth.uid(), jsonb_build_object('note', left(coalesce(p_note, ''), 500)))
240
+ ON CONFLICT (tenant_id, kind, day) DO NOTHING
241
+ RETURNING true INTO v_inserted;
242
+ IF NOT coalesce(v_inserted, false) THEN
243
+ RETURN jsonb_build_object('ok', true, 'already_requested_today', true);
244
+ END IF;
245
+
246
+ INSERT INTO public.plg_getting_started_journeys (tenant_id, wants_contract_at)
247
+ VALUES (v_tenant, now())
248
+ ON CONFLICT (tenant_id) DO UPDATE
249
+ SET wants_contract_at = coalesce(public.plg_getting_started_journeys.wants_contract_at, now()),
250
+ updated_at = now();
251
+
252
+ PERFORM public.plg_emit_event('getting_started.wants_contract',
253
+ jsonb_build_object('note', left(coalesce(p_note, ''), 500)), 'tenant', v_tenant::text, v_tenant);
254
+
255
+ RETURN jsonb_build_object('ok', true);
256
+ END $$;
257
+ REVOKE ALL ON FUNCTION public.getting_started_request_contract(uuid, text) FROM PUBLIC;
258
+ GRANT EXECUTE ON FUNCTION public.getting_started_request_contract(uuid, text) TO authenticated, service_role;
259
+
260
+ -- \u2500\u2500 reminders D\u22123 / D0 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
261
+ -- A tenant's calendar day. A bad zone name must not take the whole run down.
262
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_local_date(p_at timestamptz, p_tz text)
263
+ RETURNS date LANGUAGE plpgsql STABLE SET search_path TO '' AS $$
264
+ BEGIN
265
+ RETURN (p_at AT TIME ZONE coalesce(nullif(p_tz, ''), 'UTC'))::date;
266
+ EXCEPTION WHEN OTHERS THEN
267
+ RETURN (p_at AT TIME ZONE 'UTC')::date;
268
+ END $$;
269
+
270
+ -- Who is due today. \`p_now\` is injectable so QA can simulate the day without touching the clock.
271
+ -- Due-ness is by the tenant's local day; the once-a-day lock is by the run's UTC day, so a
272
+ -- reminder sent before an extend does not block the one the new end date earns.
273
+ CREATE OR REPLACE FUNCTION public.getting_started_reminders_due(p_now timestamptz DEFAULT now())
274
+ RETURNS TABLE (tenant_id uuid, kind text, trial_ends_at timestamptz)
275
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$
276
+ WITH due AS (
277
+ SELECT j.tenant_id, t.trial_ends_at,
278
+ public.plg_getting_started_local_date(t.trial_ends_at, t.settings ->> 'timezone') AS end_day,
279
+ public.plg_getting_started_local_date(p_now, t.settings ->> 'timezone') AS today
280
+ FROM public.plg_getting_started_journeys j
281
+ JOIN public.tenants t ON t.id = j.tenant_id
282
+ WHERE j.trial_started_at IS NOT NULL
283
+ AND j.converted_at IS NULL
284
+ AND t.trial_ends_at IS NOT NULL
285
+ )
286
+ SELECT d.tenant_id,
287
+ CASE WHEN d.end_day = d.today + 3 THEN 'reminder_d3' ELSE 'reminder_d0' END AS kind,
288
+ d.trial_ends_at
289
+ FROM due d
290
+ WHERE d.end_day IN (d.today + 3, d.today)
291
+ AND NOT EXISTS (
292
+ SELECT 1 FROM public.plg_getting_started_events e
293
+ WHERE e.tenant_id = d.tenant_id
294
+ AND e.day = (p_now AT TIME ZONE 'UTC')::date
295
+ AND e.kind = CASE WHEN d.end_day = d.today + 3 THEN 'reminder_d3' ELSE 'reminder_d0' END
296
+ );
297
+ $$;
298
+ REVOKE ALL ON FUNCTION public.getting_started_reminders_due(timestamptz) FROM PUBLIC, anon, authenticated;
299
+ GRANT EXECUTE ON FUNCTION public.getting_started_reminders_due(timestamptz) TO service_role;
300
+
301
+ -- Templates are per tenant (notifications UNIQUE (tenant_id, key, channel, locale)).
302
+ CREATE OR REPLACE FUNCTION public.getting_started_ensure_templates(p_tenant uuid)
303
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
304
+ BEGIN
305
+ IF to_regclass('public.plg_notifications_templates') IS NULL THEN RETURN; END IF;
306
+ INSERT INTO public.plg_notifications_templates (tenant_id, key, channel, locale, subject, body, variables, is_default)
307
+ VALUES
308
+ (p_tenant, 'getting_started.reminder_d3', 'in_app', 'pt-BR', 'Seu teste termina em 3 dias',
309
+ 'Faltam 3 dias de teste gr\xE1tis. At\xE9 l\xE1 est\xE1 tudo liberado. Para continuar depois, \xE9 s\xF3 escolher um plano em Configura\xE7\xF5es \u203A Assinatura \u2014 sem fidelidade.',
310
+ '["trial_ends_at"]'::jsonb, true),
311
+ (p_tenant, 'getting_started.reminder_d0', 'in_app', 'pt-BR', 'Hoje \xE9 o \xFAltimo dia do seu teste',
312
+ 'Termina hoje. Depois disso a loja continua e seus dados tamb\xE9m; o que \xE9 do plano pago fica fechado at\xE9 voc\xEA assinar.',
313
+ '["trial_ends_at"]'::jsonb, true)
314
+ ON CONFLICT (tenant_id, key, channel, locale) DO NOTHING;
315
+ END $$;
316
+ REVOKE ALL ON FUNCTION public.getting_started_ensure_templates(uuid) FROM PUBLIC, anon, authenticated;
317
+ GRANT EXECUTE ON FUNCTION public.getting_started_ensure_templates(uuid) TO service_role;
318
+
319
+ -- The job. Writes the event BEFORE enqueueing (the lock), then enqueues through notifications when present.
320
+ CREATE OR REPLACE FUNCTION public.getting_started_reminders_run(p_now timestamptz DEFAULT now())
321
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
322
+ DECLARE
323
+ r record;
324
+ v_day date := (p_now AT TIME ZONE 'UTC')::date;
325
+ v_person uuid;
326
+ v_sent integer := 0;
327
+ v_skipped integer := 0;
328
+ v_queue uuid;
329
+ v_enqueue boolean := to_regprocedure('public.notifications_enqueue(uuid,text,uuid,jsonb,text,timestamptz,text,text,uuid,uuid)') IS NOT NULL;
330
+ BEGIN
331
+ IF NOT pg_try_advisory_xact_lock(hashtext('getting_started_reminders')) THEN
332
+ RETURN jsonb_build_object('skipped', 'another run is active');
333
+ END IF;
334
+
335
+ FOR r IN SELECT * FROM public.getting_started_reminders_due(p_now) LOOP
336
+ -- The owner: the team person whose e-mail is the first active member's.
337
+ SELECT pe.id INTO v_person
338
+ FROM app.memberships m
339
+ JOIN auth.users u ON u.id = m.user_id
340
+ JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)
341
+ WHERE m.tenant_id = r.tenant_id AND m.active
342
+ ORDER BY m.created_at
343
+ LIMIT 1;
344
+
345
+ INSERT INTO public.plg_getting_started_events (tenant_id, kind, day, channel, payload)
346
+ VALUES (r.tenant_id, r.kind, v_day, CASE WHEN v_person IS NULL THEN 'none' ELSE 'in_app' END,
347
+ jsonb_build_object('trial_ends_at', r.trial_ends_at))
348
+ ON CONFLICT (tenant_id, kind, day) DO NOTHING;
349
+ IF NOT FOUND THEN v_skipped := v_skipped + 1; CONTINUE; END IF;
350
+
351
+ IF v_person IS NULL OR NOT v_enqueue THEN
352
+ v_skipped := v_skipped + 1; CONTINUE;
353
+ END IF;
354
+
355
+ PERFORM public.getting_started_ensure_templates(r.tenant_id);
356
+ BEGIN
357
+ EXECUTE 'SELECT public.notifications_enqueue($1, $2, $3, $4, $5, $6, $7)'
358
+ INTO v_queue
359
+ USING r.tenant_id, 'getting_started.' || r.kind, v_person,
360
+ jsonb_build_object('trial_ends_at', r.trial_ends_at),
361
+ 'getting_started:' || r.kind || ':' || r.tenant_id || ':' || v_day, p_now, 'in_app';
362
+ v_sent := v_sent + 1;
363
+ EXCEPTION WHEN OTHERS THEN
364
+ -- A provider down for one tenant does not stop the others; the event already marked the day.
365
+ UPDATE public.plg_getting_started_events SET payload = payload || jsonb_build_object('error', SQLERRM)
366
+ WHERE tenant_id = r.tenant_id AND kind = r.kind AND day = v_day;
367
+ v_skipped := v_skipped + 1;
368
+ END;
369
+ END LOOP;
370
+
371
+ RETURN jsonb_build_object('sent', v_sent, 'skipped', v_skipped);
372
+ END $$;
373
+ REVOKE ALL ON FUNCTION public.getting_started_reminders_run(timestamptz) FROM PUBLIC, anon, authenticated;
374
+ GRANT EXECUTE ON FUNCTION public.getting_started_reminders_run(timestamptz) TO service_role;
375
+
376
+ -- cron: the spine's guard, plus a row in app.scheduled_jobs so the schedule is visible without pg_cron.
377
+ DO $cron$
378
+ DECLARE v_hour integer;
379
+ BEGIN
380
+ SELECT reminder_hour_utc INTO v_hour FROM public.plg_getting_started_config;
381
+ v_hour := coalesce(v_hour, 13);
382
+ INSERT INTO app.scheduled_jobs (job_name, schedule, command, scheduled, reason)
383
+ VALUES ('fayz_getting_started_reminders', format('0 %s * * *', v_hour), 'SELECT public.getting_started_reminders_run()', false, 'pending')
384
+ ON CONFLICT (job_name) DO UPDATE
385
+ SET schedule = EXCLUDED.schedule, command = EXCLUDED.command, updated_at = now();
386
+ IF to_regprocedure('cron.schedule(text, text, text)') IS NULL THEN
387
+ UPDATE app.scheduled_jobs SET scheduled = false, reason = 'pg_cron absent' WHERE job_name = 'fayz_getting_started_reminders';
388
+ RAISE NOTICE 'getting-started: pg_cron absent \u2014 reminders not scheduled';
389
+ RETURN;
390
+ END IF;
391
+ PERFORM cron.unschedule(jobid) FROM cron.job WHERE jobname = 'fayz_getting_started_reminders';
392
+ PERFORM cron.schedule('fayz_getting_started_reminders', format('0 %s * * *', v_hour), $job$SELECT public.getting_started_reminders_run()$job$);
393
+ UPDATE app.scheduled_jobs SET scheduled = true, reason = NULL WHERE job_name = 'fayz_getting_started_reminders';
394
+ END $cron$;
395
+
396
+ -- \u2500\u2500 the lead in Faya's CRM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
397
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)
398
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
399
+ DECLARE
400
+ v_crm uuid;
401
+ v_slug text;
402
+ v_name text;
403
+ v_email text;
404
+ v_phone text;
405
+ v_lead uuid;
406
+ v_j record;
407
+ BEGIN
408
+ IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN
409
+ RETURN jsonb_build_object('skipped', 'plugin-crm absent');
410
+ END IF;
411
+ SELECT c.crm_tenant_slug INTO v_slug FROM public.plg_getting_started_config c;
412
+ SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_slug;
413
+ IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;
414
+
415
+ SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;
416
+ IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;
417
+
418
+ SELECT u.email, pe.phone INTO v_email, v_phone
419
+ FROM app.memberships m
420
+ JOIN auth.users u ON u.id = m.user_id
421
+ LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)
422
+ WHERE m.tenant_id = p_tenant AND m.active
423
+ ORDER BY m.created_at
424
+ LIMIT 1;
425
+ -- create_public_lead raises on a phone outside 8\u201315 digits; a bad number is dropped, not fatal.
426
+ v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\D', '', 'g'), '');
427
+ IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;
428
+ IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;
429
+
430
+ SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;
431
+
432
+ -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.
433
+ SELECT l.lead_id INTO v_lead FROM public.create_public_lead(
434
+ v_crm, left(rpad(btrim(v_name), 2, '.'), 120), v_phone, v_email,
435
+ 'getting-started', 'Teste gr\xE1tis \u2014 ' || coalesce(v_slug, ''),
436
+ jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,
437
+ 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)
438
+ || coalesce(p_extra, '{}'::jsonb),
439
+ NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;
440
+ RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag);
441
+ END $$;
442
+ REVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
443
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;
444
+
445
+ -- Handlers in the shape consume_event_log runs: public.<handler>(jsonb), where the argument is
446
+ -- to_jsonb(event_log row) \u2014 so \`tenant_id\` and \`payload\` are top-level keys.
447
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)
448
+ RETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$
449
+ SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));
450
+ $$;
451
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_on_wants_contract(p_event jsonb)
452
+ RETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$
453
+ SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'quer_contratar', coalesce(p_event -> 'payload', '{}'::jsonb));
454
+ $$;
455
+ REVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;
456
+ REVOKE ALL ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) FROM PUBLIC, anon, authenticated;
457
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;
458
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) TO service_role;
459
+
460
+ SELECT public.register_event_subscription('getting-started', 'getting_started.trial_started', 'plg_getting_started_on_trial_started', 'Faya lead when the trial starts', 5);
461
+ SELECT 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);
462
+ `;
463
+ var MIGRATION_002_PLATFORM_OPERATOR = `-- 002_platform_operator.sql \u2014 the platform reads every tenant's trial and acts on it.
464
+ -- Functions only; no new table. Depends on 000_journey.sql and 001_trial_clock.sql.
465
+ --
466
+ -- "Platform operator" = an active member of the tenant named by
467
+ -- plg_getting_started_config.crm_tenant_slug, acting INSIDE that tenant
468
+ -- (app.current_tenant_id()), or \u2014 where the live cluster has the platform
469
+ -- schema \u2014 platform.can(auth.uid(), 'tenants.write'). Same shape as
470
+ -- support_is_operator(): the boundary is a SECURITY DEFINER function with a
471
+ -- guard; no RLS policy opens between tenants.
472
+
473
+ -- \u2500\u2500 who is the platform \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
474
+ CREATE OR REPLACE FUNCTION public.getting_started_platform_tenant()
475
+ RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$
476
+ SELECT t.id
477
+ FROM public.plg_getting_started_config c
478
+ JOIN public.tenants t ON t.slug = c.crm_tenant_slug
479
+ LIMIT 1;
480
+ $$;
481
+ REVOKE ALL ON FUNCTION public.getting_started_platform_tenant() FROM PUBLIC;
482
+ GRANT EXECUTE ON FUNCTION public.getting_started_platform_tenant() TO authenticated, service_role;
483
+
484
+ CREATE OR REPLACE FUNCTION public.getting_started_is_platform_operator()
485
+ RETURNS boolean LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$
486
+ DECLARE
487
+ v_uid uuid := auth.uid();
488
+ v_platform uuid := public.getting_started_platform_tenant();
489
+ v_can boolean := false;
490
+ BEGIN
491
+ IF v_uid IS NULL THEN RETURN false; END IF;
492
+ IF v_platform IS NOT NULL AND v_platform = app.current_tenant_id() AND EXISTS (
493
+ SELECT 1 FROM app.memberships m
494
+ WHERE m.tenant_id = v_platform AND m.user_id = v_uid AND m.active) THEN
495
+ RETURN true;
496
+ END IF;
497
+ -- Optional reinforcement: the platform schema lives only on the live cluster.
498
+ IF to_regprocedure('platform.can(uuid,text)') IS NOT NULL THEN
499
+ EXECUTE 'SELECT platform.can($1, $2)' INTO v_can USING v_uid, 'tenants.write';
500
+ END IF;
501
+ RETURN coalesce(v_can, false);
502
+ END $$;
503
+ REVOKE ALL ON FUNCTION public.getting_started_is_platform_operator() FROM PUBLIC;
504
+ GRANT EXECUTE ON FUNCTION public.getting_started_is_platform_operator() TO authenticated, service_role;
505
+
506
+ CREATE OR REPLACE FUNCTION public.getting_started_platform_guard()
507
+ RETURNS void LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$
508
+ BEGIN
509
+ IF NOT public.getting_started_is_platform_operator() THEN
510
+ RAISE EXCEPTION 'not a platform operator' USING ERRCODE = '42501';
511
+ END IF;
512
+ END $$;
513
+ REVOKE ALL ON FUNCTION public.getting_started_platform_guard() FROM PUBLIC;
514
+ GRANT EXECUTE ON FUNCTION public.getting_started_platform_guard() TO authenticated, service_role;
515
+
516
+ -- \u2500\u2500 timeline note that survives a repeat on the same day \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
517
+ -- plg_getting_started_events is UNIQUE (tenant_id, kind, day). A second
518
+ -- extension on the same day keeps one row and folds the latest payload in,
519
+ -- counting the repeats; the customer-success timeline keeps every action.
520
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_note(p_tenant uuid, p_kind text, p_payload jsonb, p_actor uuid DEFAULT auth.uid())
521
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
522
+ BEGIN
523
+ INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)
524
+ VALUES (p_tenant, p_kind, p_actor, coalesce(p_payload, '{}'::jsonb))
525
+ ON CONFLICT (tenant_id, kind, day) DO UPDATE
526
+ SET payload = public.plg_getting_started_events.payload || EXCLUDED.payload
527
+ || jsonb_build_object('repeats', coalesce((public.plg_getting_started_events.payload ->> 'repeats')::int, 0) + 1),
528
+ actor_id = coalesce(EXCLUDED.actor_id, public.plg_getting_started_events.actor_id);
529
+ END $$;
530
+ REVOKE ALL ON FUNCTION public.plg_getting_started_note(uuid, text, jsonb, uuid) FROM PUBLIC, anon, authenticated;
531
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_note(uuid, text, jsonb, uuid) TO service_role;
532
+
533
+ -- \u2500\u2500 every tenant's journey, for the platform \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
534
+ CREATE OR REPLACE FUNCTION public.getting_started_platform_journeys(p_search text DEFAULT NULL, p_phase text DEFAULT NULL)
535
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$
536
+ DECLARE
537
+ v_platform uuid := public.getting_started_platform_tenant();
538
+ v_search text := nullif(btrim(coalesce(p_search, '')), '');
539
+ v_result jsonb;
540
+ BEGIN
541
+ PERFORM public.getting_started_platform_guard();
542
+ SELECT coalesce(jsonb_agg(to_jsonb(r) ORDER BY r.tenant_created_at DESC), '[]'::jsonb) INTO v_result
543
+ FROM (
544
+ SELECT t.id AS tenant_id,
545
+ t.name AS tenant_name,
546
+ t.slug AS tenant_slug,
547
+ t.vertical_id,
548
+ t.plan AS pool_plan,
549
+ t.settings #>> '{billing,plan_id}' AS grants_plan,
550
+ t.kind,
551
+ t.created_at AS tenant_created_at,
552
+ coalesce((SELECT array_agg(ta.app_id ORDER BY ta.created_at)
553
+ FROM app.tenant_apps ta WHERE ta.tenant_id = t.id AND ta.status = 'active'), '{}'::text[]) AS apps,
554
+ coalesce((SELECT l.state FROM app.tenant_lifecycle l WHERE l.tenant_id = t.id), 'active') AS lifecycle_state,
555
+ coalesce(j.phase, 'setup') AS phase,
556
+ j.ready_at, j.trial_started_at, j.trial_ends_at, j.setup_deadline_at, j.converted_at, j.wants_contract_at,
557
+ coalesce(j.trial_days, c.trial_days) AS trial_days,
558
+ j.source, coalesce(j.utm, '{}'::jsonb) AS utm,
559
+ public.getting_started_minimums(t.id) AS minimums,
560
+ (SELECT jsonb_build_object('email', u.email, 'name', pe.name, 'phone', pe.phone)
561
+ FROM app.memberships m
562
+ JOIN auth.users u ON u.id = m.user_id
563
+ LEFT JOIN public.people pe ON pe.id = m.person_id
564
+ WHERE m.tenant_id = t.id AND m.active
565
+ ORDER BY m.created_at LIMIT 1) AS contact,
566
+ (SELECT jsonb_build_object('kind', e.kind, 'created_at', e.created_at)
567
+ FROM public.plg_getting_started_events e
568
+ WHERE e.tenant_id = t.id ORDER BY e.created_at DESC LIMIT 1) AS last_event,
569
+ (SELECT count(*) FROM public.plg_getting_started_events e WHERE e.tenant_id = t.id) AS events_count
570
+ FROM public.tenants t
571
+ CROSS JOIN public.plg_getting_started_config c
572
+ LEFT JOIN public.v_getting_started_journey j ON j.tenant_id = t.id
573
+ WHERE (v_platform IS NULL OR t.id <> v_platform)
574
+ AND (p_phase IS NULL OR coalesce(j.phase, 'setup') = p_phase)
575
+ AND (v_search IS NULL
576
+ OR t.name ILIKE '%' || v_search || '%'
577
+ OR t.slug ILIKE '%' || v_search || '%'
578
+ OR EXISTS (SELECT 1 FROM app.memberships m JOIN auth.users u ON u.id = m.user_id
579
+ WHERE m.tenant_id = t.id AND m.active AND u.email ILIKE '%' || v_search || '%'))
580
+ ) r;
581
+ RETURN v_result;
582
+ END $$;
583
+ REVOKE ALL ON FUNCTION public.getting_started_platform_journeys(text, text) FROM PUBLIC;
584
+ GRANT EXECUTE ON FUNCTION public.getting_started_platform_journeys(text, text) TO authenticated, service_role;
585
+
586
+ -- \u2500\u2500 act on one tenant's trial \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
587
+ -- extend : adds p_days (1..90) on top of greatest(trial_ends_at, now()) \u2014 V1 \xA76.6.
588
+ -- end_now : trial_ends_at = now(); emits getting_started.expired.
589
+ -- convert : converted_at = coalesce(converted_at, now()); emits getting_started.converted.
590
+ -- remind : one in_app notice to the first member, template getting_started.reminder_d0.
591
+ -- The operator is not a member of the house, and plg_emit_event refuses a caller outside
592
+ -- the tenant. The guard above already authorised the platform; file the fact directly.
593
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_platform_emit(p_tenant uuid, p_key text, p_payload jsonb)
594
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
595
+ DECLARE v_id uuid;
596
+ BEGIN
597
+ INSERT INTO public.event_log (tenant_id, event_key, payload, actor_id, subject_type, subject_id)
598
+ VALUES (p_tenant, p_key, coalesce(p_payload, '{}'::jsonb), auth.uid(), 'tenant', p_tenant::text)
599
+ RETURNING id INTO v_id;
600
+ RETURN v_id;
601
+ EXCEPTION WHEN OTHERS THEN
602
+ RAISE WARNING '[getting_started_trial_set] % not recorded: %', p_key, SQLERRM;
603
+ RETURN NULL;
604
+ END $$;
605
+ REVOKE ALL ON FUNCTION public.plg_getting_started_platform_emit(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
606
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_platform_emit(uuid, text, jsonb) TO service_role;
607
+
608
+ CREATE OR REPLACE FUNCTION public.getting_started_trial_set(p_tenant uuid, p_action text, p_days integer DEFAULT NULL, p_reason text DEFAULT NULL)
609
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
610
+ DECLARE
611
+ v_platform uuid := public.getting_started_platform_tenant();
612
+ v_actor uuid := auth.uid();
613
+ v_reason text := nullif(btrim(coalesce(p_reason, '')), '');
614
+ v_t record;
615
+ v_j record;
616
+ v_before jsonb;
617
+ v_after jsonb;
618
+ v_payload jsonb := '{}'::jsonb;
619
+ v_ends timestamptz;
620
+ v_converted timestamptz;
621
+ v_person uuid;
622
+ v_nonce text;
623
+ v_queue uuid;
624
+ v_enqueue boolean := to_regprocedure('public.notifications_enqueue(uuid,text,uuid,jsonb,text,timestamptz,text,text,uuid,uuid)') IS NOT NULL;
625
+ v_ok boolean := true;
626
+ BEGIN
627
+ PERFORM public.getting_started_platform_guard();
628
+ IF p_action IS NULL OR p_action NOT IN ('extend', 'end_now', 'convert', 'remind') THEN
629
+ RAISE EXCEPTION 'invalid action %', p_action USING ERRCODE = '22023';
630
+ END IF;
631
+ IF p_tenant IS NULL OR p_tenant = v_platform THEN
632
+ RAISE EXCEPTION 'tenant out of scope' USING ERRCODE = '22023';
633
+ END IF;
634
+ SELECT t.id, t.trial_ends_at INTO v_t FROM public.tenants t WHERE t.id = p_tenant FOR UPDATE;
635
+ IF NOT FOUND THEN RAISE EXCEPTION 'tenant not found' USING ERRCODE = 'P0002'; END IF;
636
+ SELECT j.trial_started_at, j.converted_at INTO v_j FROM public.plg_getting_started_journeys j WHERE j.tenant_id = p_tenant;
637
+
638
+ 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);
639
+ v_ends := v_t.trial_ends_at;
640
+ v_converted := v_j.converted_at;
641
+
642
+ IF p_action = 'extend' THEN
643
+ IF p_days IS NULL OR p_days NOT BETWEEN 1 AND 90 THEN
644
+ RAISE EXCEPTION 'p_days must be between 1 and 90' USING ERRCODE = '22023';
645
+ END IF;
646
+ v_ends := greatest(coalesce(v_t.trial_ends_at, now()), now()) + make_interval(days => p_days);
647
+ INSERT INTO public.plg_getting_started_journeys (tenant_id, trial_started_at, trial_days)
648
+ VALUES (p_tenant, now(), (SELECT c.trial_days FROM public.plg_getting_started_config c))
649
+ ON CONFLICT (tenant_id) DO UPDATE
650
+ SET trial_started_at = coalesce(public.plg_getting_started_journeys.trial_started_at, EXCLUDED.trial_started_at),
651
+ updated_at = now();
652
+ -- Passes tenants_trial_is_readonly: SECURITY DEFINER runs as the owner.
653
+ UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = p_tenant;
654
+ v_payload := jsonb_build_object('days', p_days, 'ends_at', v_ends, 'reason', v_reason);
655
+ PERFORM public.plg_getting_started_note(p_tenant, 'trial_extended', v_payload, v_actor);
656
+
657
+ ELSIF p_action = 'end_now' THEN
658
+ IF v_reason IS NULL THEN RAISE EXCEPTION 'a reason is required to end a trial' USING ERRCODE = '22023'; END IF;
659
+ v_ends := now();
660
+ UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = p_tenant;
661
+ v_payload := jsonb_build_object('ends_at', v_ends, 'reason', v_reason);
662
+ PERFORM public.plg_getting_started_note(p_tenant, 'trial_ended', v_payload, v_actor);
663
+ PERFORM public.plg_getting_started_platform_emit(p_tenant, 'getting_started.expired',
664
+ jsonb_build_object('trial_ends_at', v_ends, 'reason', v_reason, 'ended_by', v_actor));
665
+
666
+ ELSIF p_action = 'convert' THEN
667
+ -- Without a proposal the operator writes why; with one, the caller passes 'proposal:<number>'.
668
+ IF v_reason IS NULL THEN RAISE EXCEPTION 'a reason is required to convert without a proposal' USING ERRCODE = '22023'; END IF;
669
+ v_converted := coalesce(v_j.converted_at, now());
670
+ INSERT INTO public.plg_getting_started_journeys (tenant_id, converted_at)
671
+ VALUES (p_tenant, v_converted)
672
+ ON CONFLICT (tenant_id) DO UPDATE
673
+ SET converted_at = coalesce(public.plg_getting_started_journeys.converted_at, EXCLUDED.converted_at),
674
+ updated_at = now();
675
+ v_payload := jsonb_build_object('converted_at', v_converted, 'reason', v_reason);
676
+ IF v_j.converted_at IS NULL THEN
677
+ PERFORM public.plg_getting_started_note(p_tenant, 'converted', v_payload, v_actor);
678
+ PERFORM public.plg_getting_started_platform_emit(p_tenant, 'getting_started.converted',
679
+ jsonb_build_object('converted_at', v_converted, 'reason', v_reason, 'converted_by', v_actor));
680
+ END IF;
681
+
682
+ ELSIF p_action = 'remind' THEN
683
+ SELECT pe.id INTO v_person
684
+ FROM app.memberships m
685
+ JOIN auth.users u ON u.id = m.user_id
686
+ JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)
687
+ WHERE m.tenant_id = p_tenant AND m.active
688
+ ORDER BY m.created_at LIMIT 1;
689
+ v_nonce := left(md5(random()::text || clock_timestamp()::text), 12);
690
+ v_payload := jsonb_build_object('trial_ends_at', v_t.trial_ends_at, 'nonce', v_nonce, 'reason', v_reason,
691
+ 'channel', CASE WHEN v_person IS NULL OR NOT v_enqueue THEN 'none' ELSE 'in_app' END);
692
+ IF v_person IS NULL OR NOT v_enqueue THEN
693
+ v_ok := false;
694
+ v_payload := v_payload || jsonb_build_object('skipped', CASE WHEN v_person IS NULL THEN 'no contact' ELSE 'plugin-notifications absent' END);
695
+ ELSE
696
+ PERFORM public.getting_started_ensure_templates(p_tenant);
697
+ EXECUTE 'SELECT public.notifications_enqueue($1, $2, $3, $4, $5, $6, $7)'
698
+ INTO v_queue
699
+ USING p_tenant, 'getting_started.reminder_d0', v_person,
700
+ jsonb_build_object('trial_ends_at', v_t.trial_ends_at),
701
+ 'getting_started:reminder_manual:' || p_tenant || ':' || v_nonce, now(), 'in_app';
702
+ v_payload := v_payload || jsonb_build_object('queue_id', v_queue);
703
+ END IF;
704
+ PERFORM public.plg_getting_started_note(p_tenant, 'reminder_manual', v_payload, v_actor);
705
+ END IF;
706
+
707
+ v_after := jsonb_build_object('trial_ends_at', v_ends, 'converted_at', v_converted);
708
+ -- audit_log derives the tenant from the session for a signed-in caller; the
709
+ -- customer tenant travels in the metadata. A service_role run names the platform.
710
+ PERFORM public.audit_log('getting_started.' || p_action, 'tenant', p_tenant, v_reason, v_before, v_after, NULL,
711
+ v_payload || jsonb_build_object('customer_tenant_id', p_tenant),
712
+ CASE WHEN v_actor IS NULL THEN v_platform ELSE NULL END);
713
+
714
+ RETURN jsonb_build_object('ok', v_ok, 'action', p_action, 'tenant_id', p_tenant,
715
+ 'trial_ends_at', v_ends, 'converted_at', v_converted);
716
+ END $$;
717
+ REVOKE ALL ON FUNCTION public.getting_started_trial_set(uuid, text, integer, text) FROM PUBLIC;
718
+ GRANT EXECUTE ON FUNCTION public.getting_started_trial_set(uuid, text, integer, text) TO authenticated, service_role;
719
+ `;
720
+ var MIGRATION_003_THE_CLOCK_STARTS_ONCE_AND_ONLY_FOR_WHO_MAY = `-- 003_the_clock_starts_once_and_only_for_who_may.sql \u2014 the gates the audit found open.
721
+ -- Functions and grants only; no new table. Depends on 000, 001 and 002.
722
+ --
723
+ -- * getting_started_start_trial: the tenant row is locked (FOR UPDATE), so two
724
+ -- clicks cannot start twice and emit twice; a tenant with no trial window
725
+ -- (trial_ends_at IS NULL \u2014 legacy or paying, db 044), an already converted
726
+ -- journey or a paid plan is refused instead of being handed a clock that
727
+ -- expires on them.
728
+ -- * getting_started_minimums: a member of the tenant (or the platform
729
+ -- operator of 002, or service_role) \u2014 not any signed-in user probing other
730
+ -- tenants by id.
731
+ -- * plg_getting_started_config: the client reads only the columns it needs;
732
+ -- crm_tenant_slug is the platform's business.
733
+ -- * the event handlers accept only what the RPCs wrote \u2014 plg_emit_event is
734
+ -- open to any member, and a hand-made \`trial_started\` must not create a lead.
735
+
736
+ -- \u2500\u2500 the minimums: only for who may ask \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
737
+ CREATE OR REPLACE FUNCTION public.getting_started_minimums(p_tenant uuid)
738
+ RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$
739
+ DECLARE
740
+ v_menu boolean := false;
741
+ v_hours boolean := false;
742
+ v_order boolean := false;
743
+ v_settings jsonb;
744
+ v_operator boolean := false;
745
+ BEGIN
746
+ IF p_tenant IS NULL THEN
747
+ RAISE EXCEPTION 'getting_started_minimums: tenant is required' USING ERRCODE = '22023';
748
+ END IF;
749
+ -- service_role (jobs), a member of the tenant, or the platform operator (002).
750
+ IF auth.role() IS DISTINCT FROM 'service_role' AND NOT app.is_member(p_tenant) THEN
751
+ IF to_regprocedure('public.getting_started_is_platform_operator()') IS NOT NULL THEN
752
+ EXECUTE 'SELECT public.getting_started_is_platform_operator()' INTO v_operator;
753
+ END IF;
754
+ IF NOT coalesce(v_operator, false) THEN
755
+ RAISE EXCEPTION 'getting_started_minimums: not a member of this tenant' USING ERRCODE = '42501';
756
+ END IF;
757
+ END IF;
758
+
759
+ SELECT t.settings INTO v_settings FROM public.tenants t WHERE t.id = p_tenant;
760
+ v_hours := jsonb_typeof(v_settings #> '{store,hours}') = 'object'
761
+ AND (v_settings #> '{store,hours}') <> '{}'::jsonb;
762
+ -- A pool without plugin-menu must not break the read.
763
+ IF to_regclass('public.plg_menu_items') IS NOT NULL THEN
764
+ EXECUTE $q$SELECT EXISTS (
765
+ SELECT 1 FROM public.plg_menu_items mi
766
+ JOIN public.products p ON p.id = mi.product_id
767
+ WHERE mi.tenant_id = $1 AND mi.status = 'available' AND p.is_active
768
+ AND coalesce(p.metadata->>'demo','') <> 'true')$q$
769
+ INTO v_menu USING p_tenant;
770
+ END IF;
771
+ SELECT EXISTS (
772
+ SELECT 1 FROM public.orders o
773
+ WHERE o.tenant_id = p_tenant AND o.status = 'completed'
774
+ AND o.metadata @> '{"test":true}'::jsonb
775
+ ) INTO v_order;
776
+ RETURN jsonb_build_object(
777
+ 'menu', v_menu, 'hours', v_hours, 'test_order', v_order,
778
+ 'ready', v_menu AND v_hours AND v_order
779
+ );
780
+ END $$;
781
+ REVOKE ALL ON FUNCTION public.getting_started_minimums(uuid) FROM PUBLIC;
782
+ GRANT EXECUTE ON FUNCTION public.getting_started_minimums(uuid) TO authenticated, service_role;
783
+
784
+ -- \u2500\u2500 start the trial: once, locked, and only for a tenant that can be on trial \u2500
785
+ CREATE OR REPLACE FUNCTION public.getting_started_start_trial(p_tenant uuid DEFAULT NULL)
786
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
787
+ DECLARE
788
+ v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());
789
+ v_days integer;
790
+ v_min jsonb;
791
+ v_t record;
792
+ v_j record;
793
+ v_started timestamptz;
794
+ v_ends timestamptz;
795
+ BEGIN
796
+ -- Same gate as tenant_setting_set: a session, the tenant in scope, settings.manage.
797
+ IF auth.uid() IS NULL OR v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN
798
+ RAISE EXCEPTION 'getting_started_start_trial: no session or tenant out of scope' USING ERRCODE = '42501';
799
+ END IF;
800
+ IF NOT app.has_permission('settings.manage') THEN
801
+ RAISE EXCEPTION 'getting_started_start_trial: settings.manage is required' USING ERRCODE = '42501';
802
+ END IF;
803
+
804
+ -- The lock: a second concurrent call waits here and then sees the start.
805
+ SELECT t.id, t.trial_ends_at, t.plan INTO v_t FROM public.tenants t WHERE t.id = v_tenant FOR UPDATE;
806
+ IF NOT FOUND THEN
807
+ RAISE EXCEPTION 'getting_started_start_trial: tenant not found' USING ERRCODE = 'P0002';
808
+ END IF;
809
+ SELECT j.trial_started_at, j.converted_at INTO v_j
810
+ FROM public.plg_getting_started_journeys j WHERE j.tenant_id = v_tenant FOR UPDATE;
811
+
812
+ -- Idempotent: a second call returns the state and leaves the date alone.
813
+ IF v_j.trial_started_at IS NOT NULL THEN
814
+ RETURN jsonb_build_object('ok', true, 'already_started', true, 'trial_started_at', v_j.trial_started_at,
815
+ 'trial_ends_at', v_t.trial_ends_at);
816
+ END IF;
817
+
818
+ v_min := public.getting_started_minimums(v_tenant);
819
+
820
+ -- Who is already converted, or already paying, is not offered a clock that
821
+ -- would expire on them. \`trial_ends_at IS NULL\` is the legacy/paying tenant
822
+ -- (db 044): no window, no trial.
823
+ IF v_j.converted_at IS NOT NULL THEN
824
+ RETURN jsonb_build_object('ok', false, 'reason', 'already_converted', 'minimums', v_min);
825
+ END IF;
826
+ IF coalesce(nullif(btrim(v_t.plan), ''), 'free') <> 'free' THEN
827
+ RETURN jsonb_build_object('ok', false, 'reason', 'already_paid', 'minimums', v_min, 'plan', v_t.plan);
828
+ END IF;
829
+ IF v_t.trial_ends_at IS NULL THEN
830
+ RETURN jsonb_build_object('ok', false, 'reason', 'no_trial_window', 'minimums', v_min);
831
+ END IF;
832
+
833
+ -- Minimums are checked here, not in the button.
834
+ IF NOT coalesce((v_min ->> 'ready')::boolean, false) THEN
835
+ RETURN jsonb_build_object('ok', false, 'reason', 'minimums_missing', 'minimums', v_min);
836
+ END IF;
837
+
838
+ SELECT c.trial_days INTO v_days FROM public.plg_getting_started_config c;
839
+ v_days := coalesce(v_days, 14);
840
+ v_started := now();
841
+ v_ends := v_started + make_interval(days => v_days);
842
+
843
+ INSERT INTO public.plg_getting_started_journeys (tenant_id, ready_at, trial_started_at, trial_days)
844
+ VALUES (v_tenant, v_started, v_started, v_days)
845
+ ON CONFLICT (tenant_id) DO UPDATE
846
+ SET ready_at = coalesce(public.plg_getting_started_journeys.ready_at, EXCLUDED.ready_at),
847
+ trial_started_at = EXCLUDED.trial_started_at,
848
+ trial_days = EXCLUDED.trial_days,
849
+ updated_at = now();
850
+
851
+ -- The real clock. Passes tenants_trial_is_readonly (db 221): this DEFINER
852
+ -- function runs as its owner, which the gate lets through.
853
+ UPDATE public.tenants SET trial_ends_at = v_ends WHERE id = v_tenant;
854
+
855
+ INSERT INTO public.plg_getting_started_events (tenant_id, kind, actor_id, payload)
856
+ VALUES (v_tenant, 'trial_started', auth.uid(), jsonb_build_object('trial_days', v_days, 'ends_at', v_ends))
857
+ ON CONFLICT (tenant_id, kind, day) DO NOTHING;
858
+
859
+ PERFORM public.plg_emit_event('getting_started.trial_started',
860
+ jsonb_build_object('trial_days', v_days, 'trial_ends_at', v_ends), 'tenant', v_tenant::text, v_tenant);
861
+
862
+ RETURN jsonb_build_object('ok', true, 'trial_started_at', v_started, 'trial_ends_at', v_ends, 'trial_days', v_days);
863
+ END $$;
864
+ REVOKE ALL ON FUNCTION public.getting_started_start_trial(uuid) FROM PUBLIC;
865
+ GRANT EXECUTE ON FUNCTION public.getting_started_start_trial(uuid) TO authenticated, service_role;
866
+
867
+ -- \u2500\u2500 the platform config: the client reads only what it needs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
868
+ -- The view (000) reads trial_days; the setup window is documentation of the
869
+ -- spine default. crm_tenant_slug and reminder_hour_utc are read inside the
870
+ -- DEFINER functions only. The policy stays (USING true); the GRANT narrows.
871
+ REVOKE SELECT ON public.plg_getting_started_config FROM authenticated;
872
+ GRANT SELECT (id, trial_days, setup_window_days) ON public.plg_getting_started_config TO authenticated;
873
+
874
+ -- \u2500\u2500 the handlers accept only what the RPCs wrote \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
875
+ -- plg_emit_event is open to any member of the tenant; the journey row and the
876
+ -- timeline are written by DEFINER RPCs only (no write policy). So a lead is
877
+ -- created only when the mark the RPC leaves is there.
878
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)
879
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
880
+ DECLARE
881
+ v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;
882
+ BEGIN
883
+ IF v_tenant IS NULL THEN
884
+ RETURN jsonb_build_object('skipped', 'no tenant');
885
+ END IF;
886
+ IF NOT EXISTS (
887
+ SELECT 1 FROM public.plg_getting_started_journeys j
888
+ WHERE j.tenant_id = v_tenant AND j.trial_started_at IS NOT NULL)
889
+ OR NOT EXISTS (
890
+ SELECT 1 FROM public.plg_getting_started_events e
891
+ WHERE e.tenant_id = v_tenant AND e.kind = 'trial_started') THEN
892
+ RETURN jsonb_build_object('skipped', 'trial_started was not written by getting_started_start_trial');
893
+ END IF;
894
+ RETURN public.plg_getting_started_lead(v_tenant, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));
895
+ END $$;
896
+
897
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_on_wants_contract(p_event jsonb)
898
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
899
+ DECLARE
900
+ v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;
901
+ BEGIN
902
+ IF v_tenant IS NULL THEN
903
+ RETURN jsonb_build_object('skipped', 'no tenant');
904
+ END IF;
905
+ IF NOT EXISTS (
906
+ SELECT 1 FROM public.plg_getting_started_journeys j
907
+ WHERE j.tenant_id = v_tenant AND j.wants_contract_at IS NOT NULL)
908
+ OR NOT EXISTS (
909
+ SELECT 1 FROM public.plg_getting_started_events e
910
+ WHERE e.tenant_id = v_tenant AND e.kind = 'wants_contract'
911
+ AND e.created_at >= now() - interval '2 days') THEN
912
+ RETURN jsonb_build_object('skipped', 'wants_contract was not written by getting_started_request_contract');
913
+ END IF;
914
+ RETURN public.plg_getting_started_lead(v_tenant, 'quer_contratar', coalesce(p_event -> 'payload', '{}'::jsonb));
915
+ END $$;
916
+ REVOKE ALL ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) FROM PUBLIC, anon, authenticated;
917
+ REVOKE ALL ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) FROM PUBLIC, anon, authenticated;
918
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_on_trial_started(jsonb) TO service_role;
919
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_on_wants_contract(jsonb) TO service_role;
920
+ `;
921
+ var MIGRATION_004_LEAD_KEEPS_THE_NAME = `-- 004_lead_keeps_the_name.sql \u2014 001 padded the house name with rpad(name, 2), which also CUTS it to 2
922
+ -- characters: every lead in the Faya CRM was born named "Bu". Pad only names shorter than 2.
923
+
924
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_lead(p_tenant uuid, p_tag text, p_extra jsonb DEFAULT '{}'::jsonb)
925
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
926
+ DECLARE
927
+ v_crm uuid;
928
+ v_slug text;
929
+ v_name text;
930
+ v_email text;
931
+ v_phone text;
932
+ v_lead uuid;
933
+ v_j record;
934
+ BEGIN
935
+ IF to_regprocedure('public.create_public_lead(uuid,text,text,text,text,text,jsonb,text,jsonb,text[],jsonb,text)') IS NULL THEN
936
+ RETURN jsonb_build_object('skipped', 'plugin-crm absent');
937
+ END IF;
938
+ SELECT c.crm_tenant_slug INTO v_slug FROM public.plg_getting_started_config c;
939
+ SELECT t.id INTO v_crm FROM public.tenants t WHERE t.slug = v_slug;
940
+ IF v_crm IS NULL OR v_crm = p_tenant THEN RETURN jsonb_build_object('skipped', 'crm tenant not found'); END IF;
941
+
942
+ SELECT t.name, t.slug INTO v_name, v_slug FROM public.tenants t WHERE t.id = p_tenant;
943
+ IF v_name IS NULL THEN RETURN jsonb_build_object('skipped', 'tenant not found'); END IF;
944
+
945
+ SELECT u.email, pe.phone INTO v_email, v_phone
946
+ FROM app.memberships m
947
+ JOIN auth.users u ON u.id = m.user_id
948
+ LEFT JOIN public.people pe ON pe.tenant_id = m.tenant_id AND lower(pe.email) = lower(u.email)
949
+ WHERE m.tenant_id = p_tenant AND m.active
950
+ ORDER BY m.created_at
951
+ LIMIT 1;
952
+ -- create_public_lead raises on a phone outside 8\u201315 digits; a bad number is dropped, not fatal.
953
+ v_phone := nullif(regexp_replace(coalesce(v_phone, ''), '\\D', '', 'g'), '');
954
+ IF v_phone IS NOT NULL AND length(v_phone) NOT BETWEEN 8 AND 15 THEN v_phone := NULL; END IF;
955
+ IF v_email IS NULL AND v_phone IS NULL THEN RETURN jsonb_build_object('skipped', 'no contact'); END IF;
956
+
957
+ SELECT * INTO v_j FROM public.v_getting_started_journey v WHERE v.tenant_id = p_tenant;
958
+
959
+ -- Dedupe by e-mail/phone is create_public_lead's own; the tag says where in the journey this is.
960
+ SELECT l.lead_id INTO v_lead FROM public.create_public_lead(
961
+ 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,
962
+ 'getting-started', 'Teste gr\xE1tis \u2014 ' || coalesce(v_slug, ''),
963
+ jsonb_build_object('tenant_id', p_tenant, 'tenant_slug', v_slug, 'phase', v_j.phase,
964
+ 'trial_started_at', v_j.trial_started_at, 'trial_ends_at', v_j.trial_ends_at)
965
+ || coalesce(p_extra, '{}'::jsonb),
966
+ NULL, coalesce(v_j.utm, '{}'::jsonb), ARRAY['trial', p_tag], '[]'::jsonb, NULL) l;
967
+ RETURN jsonb_build_object('lead_id', v_lead, 'tag', p_tag);
968
+ END $$;
969
+ REVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
970
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;
971
+
972
+ -- Handlers in the shape consume_event_log runs: public.<handler>(jsonb), where the argument is
973
+ -- to_jsonb(event_log row) \u2014 so \`tenant_id\` and \`payload\` are top-level keys.
974
+ CREATE OR REPLACE FUNCTION public.plg_getting_started_on_trial_started(p_event jsonb)
975
+ RETURNS jsonb LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$
976
+ SELECT public.plg_getting_started_lead((p_event ->> 'tenant_id')::uuid, 'trial_started', coalesce(p_event -> 'payload', '{}'::jsonb));
977
+ $$;
978
+
979
+ REVOKE ALL ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) FROM PUBLIC, anon, authenticated;
980
+ GRANT EXECUTE ON FUNCTION public.plg_getting_started_lead(uuid, text, jsonb) TO service_role;
981
+ `;
982
+ var MIGRATIONS = [
983
+ { id: "000_journey", sql: MIGRATION_000_JOURNEY },
984
+ { id: "001_trial_clock", sql: MIGRATION_001_TRIAL_CLOCK },
985
+ { id: "002_platform_operator", sql: MIGRATION_002_PLATFORM_OPERATOR },
986
+ { id: "003_the_clock_starts_once_and_only_for_who_may", sql: MIGRATION_003_THE_CLOCK_STARTS_ONCE_AND_ONLY_FOR_WHO_MAY },
987
+ { id: "004_lead_keeps_the_name", sql: MIGRATION_004_LEAD_KEEPS_THE_NAME }
988
+ ];
989
+
990
+ export { MIGRATIONS, MIGRATION_000_JOURNEY, MIGRATION_001_TRIAL_CLOCK, MIGRATION_002_PLATFORM_OPERATOR, MIGRATION_003_THE_CLOCK_STARTS_ONCE_AND_ONLY_FOR_WHO_MAY, MIGRATION_004_LEAD_KEEPS_THE_NAME };
991
+ //# sourceMappingURL=main.js.map
992
+ //# sourceMappingURL=main.js.map