@fayz-ai/plugin-blog 0.12.34 → 0.12.36

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.
@@ -0,0 +1,1160 @@
1
+ // src/migrations/index.ts
2
+ var MIGRATION_000_BASELINE = `-- ============================================================================
3
+ -- plugins/plugin-blog/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.
4
+ --
5
+ -- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:
6
+ -- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs
7
+ -- prove the result still matches.
8
+ --
9
+ -- This replaced 2 migration files. They are kept, unapplied, in
10
+ -- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no
11
+ -- longer worth replaying.
12
+ --
13
+ -- Object order is fixed rather than dependency-sorted: schemas, types,
14
+ -- sequences, tables, defaults, functions, constraints, views, indexes, foreign
15
+ -- keys, triggers, row security, policies, grants. Dependencies BETWEEN units
16
+ -- are carried by the order packages/db/chain.json declares.
17
+ -- ============================================================================
18
+
19
+ -- A LANGUAGE sql function binds its body at CREATE time, and 570 functions
20
+ -- sorted by name are not sorted by who calls whom. This is what lets them load
21
+ -- in any order; every body is still compiled on first call, so a genuinely
22
+ -- broken reference surfaces then rather than never. Session-scoped.
23
+ SET check_function_bodies = false;
24
+
25
+
26
+
27
+ -- \u2500\u2500 default privileges: reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
28
+ -- Cleared BEFORE anything is created, so every object below gets the ACL the
29
+ -- dump describes rather than the image's defaults on top of it. Restored at
30
+ -- the end of this file.
31
+
32
+ DO $dp$ BEGIN
33
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
34
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
35
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
36
+ END $dp$;
37
+
38
+ DO $dp$ BEGIN
39
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
40
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
41
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
42
+ END $dp$;
43
+
44
+ DO $dp$ BEGIN
45
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
46
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
47
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
48
+ END $dp$;
49
+
50
+ DO $dp$ BEGIN
51
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
52
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
53
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
54
+ END $dp$;
55
+
56
+ DO $dp$ BEGIN
57
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
58
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
59
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
60
+ END $dp$;
61
+
62
+ DO $dp$ BEGIN
63
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
64
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
65
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
66
+ END $dp$;
67
+
68
+
69
+ -- \u2500\u2500 table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
70
+
71
+ CREATE TABLE public.plg_blog_categories (
72
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
73
+ tenant_id uuid NOT NULL,
74
+ name text NOT NULL,
75
+ slug text NOT NULL,
76
+ description text,
77
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
78
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
79
+ );
80
+
81
+ CREATE TABLE public.plg_blog_posts (
82
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
83
+ tenant_id uuid NOT NULL,
84
+ category_id uuid,
85
+ slug text NOT NULL,
86
+ title text NOT NULL,
87
+ excerpt text DEFAULT ''::text NOT NULL,
88
+ body text DEFAULT ''::text NOT NULL,
89
+ cover_image text,
90
+ author_name text,
91
+ author_role text,
92
+ author_avatar_url text,
93
+ author_bio text,
94
+ status text DEFAULT 'draft'::text NOT NULL,
95
+ read_time text,
96
+ published_at timestamp with time zone,
97
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
98
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
99
+ meta_title text,
100
+ meta_description text,
101
+ canonical_url text,
102
+ og_image text,
103
+ noindex boolean DEFAULT false NOT NULL,
104
+ external_ref text,
105
+ source text DEFAULT 'admin'::text NOT NULL,
106
+ language text,
107
+ tags text[],
108
+ author_member_id uuid,
109
+ CONSTRAINT plg_blog_posts_status_check CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))
110
+ );
111
+
112
+
113
+ -- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
114
+
115
+ CREATE FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb
116
+ LANGUAGE plpgsql SECURITY DEFINER
117
+ SET search_path TO 'public'
118
+ AS $$
119
+ DECLARE
120
+ v_denial jsonb;
121
+ v_used int;
122
+ v_title text;
123
+ v_base text;
124
+ v_slug text;
125
+ v_n int := 1;
126
+ v_id uuid;
127
+ BEGIN
128
+ v_title := nullif(btrim(p_payload->>'title'), '');
129
+ IF v_title IS NULL THEN
130
+ RETURN jsonb_build_object('ok', false, 'error', 'title is required to draft a post');
131
+ END IF;
132
+
133
+ SELECT count(*) INTO v_used FROM public.plg_blog_posts p WHERE p.tenant_id = p_tenant_id;
134
+ v_denial := public.agent_guard(p_tenant_id, p_actor_user_id, 'blog', 'create', 'blog_posts', v_used, 1);
135
+ IF v_denial IS NOT NULL THEN
136
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
137
+ END IF;
138
+
139
+ v_base := coalesce(nullif(public.plg_blog_slugify(v_title), ''), 'post-' || left(md5(v_title), 8));
140
+ v_slug := left(v_base, 120);
141
+ WHILE EXISTS (SELECT 1 FROM public.plg_blog_posts p
142
+ WHERE p.tenant_id = p_tenant_id AND p.slug = v_slug) LOOP
143
+ v_n := v_n + 1;
144
+ IF v_n > 50 THEN
145
+ RETURN jsonb_build_object('ok', false, 'error', format('cannot derive a free slug from %L', v_base));
146
+ END IF;
147
+ v_slug := left(v_base, 112) || '-' || v_n;
148
+ END LOOP;
149
+
150
+ INSERT INTO public.plg_blog_posts (
151
+ tenant_id, slug, title, excerpt, body, status, source, language, tags, updated_at)
152
+ VALUES (
153
+ p_tenant_id, v_slug, v_title,
154
+ left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500),
155
+ -- Same sanitiser as the external door: the model's output is not more
156
+ -- trustworthy than a partner's, it is just closer to home.
157
+ public.plg_blog_sanitize_html(p_payload->>'body_html'),
158
+ 'draft', 'agent',
159
+ nullif(btrim(p_payload->>'language'), ''),
160
+ CASE WHEN jsonb_typeof(p_payload->'tags') = 'array'
161
+ THEN ARRAY(SELECT btrim(x) FROM jsonb_array_elements_text(p_payload->'tags') AS x WHERE btrim(x) <> '')
162
+ END,
163
+ now())
164
+ RETURNING id INTO v_id;
165
+
166
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
167
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.draftBlogPost', 'blog_post', v_id::text,
168
+ jsonb_build_object('title', v_title, 'slug', v_slug));
169
+
170
+ RETURN jsonb_build_object(
171
+ 'ok', true,
172
+ 'id', v_id,
173
+ 'record', jsonb_build_object(
174
+ 'ref', jsonb_build_object('id', v_id, 'resource', 'plg_blog_posts', 'entityKey', 'blog:posts'),
175
+ 'name', v_title,
176
+ 'slug', v_slug,
177
+ 'status', 'draft'));
178
+ END;
179
+ $$;
180
+
181
+ CREATE FUNCTION public.blog_upsert_external_post(p_payload jsonb) RETURNS jsonb
182
+ LANGUAGE plpgsql SECURITY DEFINER
183
+ SET search_path TO 'public'
184
+ AS $$
185
+ DECLARE
186
+ v_tenants uuid[];
187
+ v_tenant uuid;
188
+ v_claimed uuid;
189
+ v_ref text;
190
+ v_title text;
191
+ v_base text;
192
+ v_slug text;
193
+ v_status text;
194
+ v_source text;
195
+ v_lang text;
196
+ v_body text;
197
+ v_excerpt text;
198
+ v_tags text[];
199
+ v_author uuid;
200
+ v_pub timestamptz;
201
+ v_cover text;
202
+ v_existing_id uuid;
203
+ v_existing_pub timestamptz;
204
+ v_existing_slug text;
205
+ v_n int := 1;
206
+ v_id uuid;
207
+ v_created boolean := false;
208
+ BEGIN
209
+ -- \u2500\u2500 tenant: from the credential, never from the payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
210
+ -- A tenant id the caller supplies is how one tenant writes into another's
211
+ -- blog, and the caller is a machine outside our trust boundary. The only
212
+ -- statement about identity we can believe is the one the credential makes.
213
+ SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
214
+ IF v_tenants IS NULL OR array_length(v_tenants, 1) <> 1 THEN
215
+ RAISE EXCEPTION
216
+ 'blog_upsert_external_post: the caller must present a credential scoped to exactly one tenant'
217
+ USING ERRCODE = 'insufficient_privilege';
218
+ END IF;
219
+ v_tenant := v_tenants[1];
220
+
221
+ -- Refusing beats ignoring: a publisher that believes it is writing into
222
+ -- another tenant must be told so, not silently redirected into this one.
223
+ IF nullif(btrim(p_payload->>'tenant_id'), '') IS NOT NULL THEN
224
+ BEGIN
225
+ v_claimed := (p_payload->>'tenant_id')::uuid;
226
+ EXCEPTION WHEN OTHERS THEN
227
+ v_claimed := NULL;
228
+ END;
229
+ IF v_claimed IS DISTINCT FROM v_tenant THEN
230
+ RAISE EXCEPTION 'blog_upsert_external_post: payload names another tenant; the tenant comes from the credential'
231
+ USING ERRCODE = 'insufficient_privilege';
232
+ END IF;
233
+ END IF;
234
+
235
+ -- \u2500\u2500 the article \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
236
+ v_ref := nullif(btrim(p_payload->>'external_ref'), '');
237
+ v_title := nullif(btrim(p_payload->>'title'), '');
238
+ IF v_title IS NULL THEN
239
+ RAISE EXCEPTION 'blog_upsert_external_post: title is required'
240
+ USING ERRCODE = 'invalid_parameter_value';
241
+ END IF;
242
+
243
+ v_status := lower(coalesce(nullif(btrim(p_payload->>'status'), ''), 'draft'));
244
+ IF v_status NOT IN ('draft', 'published') THEN
245
+ RAISE EXCEPTION 'blog_upsert_external_post: unknown status %; expected draft or published', v_status
246
+ USING ERRCODE = 'invalid_parameter_value';
247
+ END IF;
248
+
249
+ -- Never taken as 'admin' by default: a post that arrived through this door is
250
+ -- external by definition, and mislabelling it is what makes a connector echo
251
+ -- it back to the system that sent it.
252
+ v_source := lower(coalesce(nullif(btrim(p_payload->>'source'), ''), 'external'));
253
+ v_lang := nullif(btrim(p_payload->>'language'), '');
254
+ v_body := public.plg_blog_sanitize_html(p_payload->>'body_html');
255
+ v_excerpt := left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500);
256
+
257
+ IF jsonb_typeof(p_payload->'tags') = 'array' THEN
258
+ SELECT array_agg(btrim(x)) INTO v_tags
259
+ FROM jsonb_array_elements_text(p_payload->'tags') AS x
260
+ WHERE btrim(x) <> '';
261
+ END IF;
262
+
263
+ -- An author id is a claim about a person in THIS tenant. Unverified, it would
264
+ -- let an external publisher put a real employee's name on anything it writes.
265
+ IF nullif(btrim(p_payload->>'author_member_id'), '') IS NOT NULL THEN
266
+ BEGIN
267
+ SELECT tm.id INTO v_author
268
+ FROM public.tenant_members tm
269
+ WHERE tm.tenant_id = v_tenant AND tm.id = (p_payload->>'author_member_id')::uuid;
270
+ EXCEPTION WHEN OTHERS THEN
271
+ v_author := NULL;
272
+ END;
273
+ END IF;
274
+
275
+ v_cover := public.plg_blog_safe_url(
276
+ coalesce(nullif(btrim(p_payload->>'cover_image'), ''), nullif(btrim(p_payload->>'og_image'), '')));
277
+
278
+ -- \u2500\u2500 identity + slug \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
279
+ IF v_ref IS NOT NULL THEN
280
+ SELECT p.id, p.published_at, p.slug INTO v_existing_id, v_existing_pub, v_existing_slug
281
+ FROM public.plg_blog_posts p
282
+ WHERE p.tenant_id = v_tenant AND p.external_ref = v_ref;
283
+ END IF;
284
+
285
+ -- On re-delivery the existing slug WINS over one re-derived from the title.
286
+ -- A published post's slug is its URL: silently re-slugging it because the
287
+ -- publisher fixed a typo in the headline breaks every inbound link \u2014 on an
288
+ -- SEO product's own articles, of all things. Changing it stays possible, but
289
+ -- only by saying so explicitly in \`slug\`.
290
+ v_base := coalesce(
291
+ nullif(public.plg_blog_slugify(p_payload->>'slug'), ''),
292
+ nullif(v_existing_slug, ''),
293
+ nullif(public.plg_blog_slugify(v_title), ''),
294
+ 'post-' || left(md5(coalesce(v_ref, gen_random_uuid()::text)), 8));
295
+ v_base := left(v_base, 120);
296
+
297
+ -- Slug is unique per tenant (001). Two different articles that slugify the
298
+ -- same must not make the second delivery fail \u2014 a suffixed slug is a smaller
299
+ -- surprise than a rejected publish.
300
+ v_slug := v_base;
301
+ WHILE EXISTS (
302
+ SELECT 1 FROM public.plg_blog_posts p
303
+ WHERE p.tenant_id = v_tenant AND p.slug = v_slug
304
+ AND (v_existing_id IS NULL OR p.id <> v_existing_id)
305
+ ) LOOP
306
+ v_n := v_n + 1;
307
+ IF v_n > 50 THEN
308
+ RAISE EXCEPTION 'blog_upsert_external_post: cannot derive a free slug from %', v_base
309
+ USING ERRCODE = 'invalid_parameter_value';
310
+ END IF;
311
+ v_slug := left(v_base, 112) || '-' || v_n;
312
+ END LOOP;
313
+
314
+ BEGIN
315
+ v_pub := nullif(btrim(p_payload->>'published_at'), '')::timestamptz;
316
+ EXCEPTION WHEN OTHERS THEN
317
+ v_pub := NULL;
318
+ END;
319
+ -- A post that goes back to draft keeps the date it first went out; the public
320
+ -- view filters on status, so nothing leaks, and the history stays true.
321
+ v_pub := coalesce(v_pub, v_existing_pub, CASE WHEN v_status = 'published' THEN now() END);
322
+
323
+ -- \u2500\u2500 write \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
324
+ IF v_existing_id IS NOT NULL THEN
325
+ UPDATE public.plg_blog_posts SET
326
+ slug = v_slug,
327
+ title = v_title,
328
+ excerpt = v_excerpt,
329
+ body = v_body,
330
+ status = v_status,
331
+ published_at = v_pub,
332
+ source = v_source,
333
+ language = v_lang,
334
+ tags = v_tags,
335
+ author_member_id = coalesce(v_author, author_member_id),
336
+ cover_image = coalesce(v_cover, cover_image),
337
+ meta_title = nullif(btrim(p_payload->>'meta_title'), ''),
338
+ meta_description = nullif(btrim(p_payload->>'meta_description'), ''),
339
+ canonical_url = public.plg_blog_safe_url(p_payload->>'canonical_url'),
340
+ og_image = public.plg_blog_safe_url(p_payload->>'og_image'),
341
+ noindex = coalesce((p_payload->>'noindex')::boolean, false),
342
+ updated_at = now()
343
+ WHERE id = v_existing_id;
344
+ v_id := v_existing_id;
345
+ ELSE
346
+ -- ON CONFLICT on the partial index, not just the SELECT above: two
347
+ -- deliveries of the same article racing each other would both find nothing
348
+ -- and both insert. The index is what makes the second one an update.
349
+ INSERT INTO public.plg_blog_posts (
350
+ tenant_id, slug, title, excerpt, body, status, published_at,
351
+ external_ref, source, language, tags, author_member_id, cover_image,
352
+ meta_title, meta_description, canonical_url, og_image, noindex, updated_at)
353
+ VALUES (
354
+ v_tenant, v_slug, v_title, v_excerpt, v_body, v_status, v_pub,
355
+ v_ref, v_source, v_lang, v_tags, v_author, v_cover,
356
+ nullif(btrim(p_payload->>'meta_title'), ''),
357
+ nullif(btrim(p_payload->>'meta_description'), ''),
358
+ public.plg_blog_safe_url(p_payload->>'canonical_url'),
359
+ public.plg_blog_safe_url(p_payload->>'og_image'),
360
+ coalesce((p_payload->>'noindex')::boolean, false),
361
+ now())
362
+ ON CONFLICT (tenant_id, external_ref) WHERE external_ref IS NOT NULL
363
+ DO UPDATE SET
364
+ title = excluded.title,
365
+ excerpt = excluded.excerpt,
366
+ body = excluded.body,
367
+ status = excluded.status,
368
+ published_at = coalesce(excluded.published_at, plg_blog_posts.published_at),
369
+ source = excluded.source,
370
+ language = excluded.language,
371
+ tags = excluded.tags,
372
+ cover_image = coalesce(excluded.cover_image, plg_blog_posts.cover_image),
373
+ meta_title = excluded.meta_title,
374
+ meta_description = excluded.meta_description,
375
+ canonical_url = excluded.canonical_url,
376
+ og_image = excluded.og_image,
377
+ noindex = excluded.noindex,
378
+ updated_at = now()
379
+ RETURNING id, (xmax = 0) INTO v_id, v_created;
380
+ END IF;
381
+
382
+ RETURN jsonb_build_object(
383
+ 'ok', true,
384
+ 'id', v_id,
385
+ 'slug', v_slug,
386
+ 'status', v_status,
387
+ 'source', v_source,
388
+ 'external_ref', v_ref,
389
+ 'created', coalesce(v_created, false));
390
+ END;
391
+ $$;
392
+
393
+ CREATE FUNCTION public.plg_blog_emit_post_event() RETURNS trigger
394
+ LANGUAGE plpgsql SECURITY DEFINER
395
+ SET search_path TO 'public'
396
+ AS $$
397
+ DECLARE
398
+ v_row jsonb;
399
+ v_tenant uuid;
400
+ v_id text;
401
+ v_payload jsonb;
402
+ BEGIN
403
+ -- A save that changed nothing is not a fact; without this any bulk re-save
404
+ -- buries the real updates.
405
+ IF TG_OP = 'UPDATE' AND OLD IS NOT DISTINCT FROM NEW THEN
406
+ RETURN NULL;
407
+ END IF;
408
+
409
+ IF TG_OP = 'DELETE' THEN
410
+ v_row := to_jsonb(OLD);
411
+ ELSE
412
+ v_row := to_jsonb(NEW);
413
+ END IF;
414
+ v_tenant := nullif(v_row->>'tenant_id', '')::uuid;
415
+ v_id := v_row->>'id';
416
+ -- The contract every consumer reads. Not the row: a whole-row payload puts
417
+ -- the article body inside every webhook by default, and whoever needs it
418
+ -- fetches by id.
419
+ v_payload := jsonb_build_object(
420
+ 'post_id', v_id,
421
+ 'slug', v_row->>'slug',
422
+ 'title', v_row->>'title',
423
+ 'status', v_row->>'status',
424
+ 'source', coalesce(v_row->>'source', 'admin'),
425
+ 'external_ref', v_row->>'external_ref',
426
+ 'language', v_row->>'language');
427
+
428
+ BEGIN
429
+ IF TG_OP = 'INSERT' THEN
430
+ PERFORM public.plg_emit_event('blog.post.created', v_payload, 'blog_post', v_id, v_tenant, NULL);
431
+ IF NEW.status = 'published' THEN
432
+ PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
433
+ END IF;
434
+ ELSIF TG_OP = 'UPDATE' THEN
435
+ PERFORM public.plg_emit_event('blog.post.updated', v_payload, 'blog_post', v_id, v_tenant, NULL);
436
+ IF NEW.status = 'published' AND OLD.status IS DISTINCT FROM 'published' THEN
437
+ PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
438
+ END IF;
439
+ ELSE
440
+ PERFORM public.plg_emit_event('blog.post.deleted', v_payload, 'blog_post', v_id, v_tenant, NULL);
441
+ END IF;
442
+ EXCEPTION WHEN OTHERS THEN
443
+ -- Observability must never be able to reject the write that caused it.
444
+ RAISE WARNING '[plg_blog_emit_post_event] % on %: %', TG_OP, TG_TABLE_NAME, SQLERRM;
445
+ END;
446
+
447
+ RETURN NULL;
448
+ END;
449
+ $$;
450
+
451
+ CREATE FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) RETURNS text
452
+ LANGUAGE sql IMMUTABLE
453
+ AS $$
454
+ SELECT coalesce(
455
+ substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*"([^"]*)"'),
456
+ substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*''([^'']*)'''),
457
+ substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*([^\\s">]+)')
458
+ );
459
+ $$;
460
+
461
+ CREATE FUNCTION public.plg_blog_html_escape(p_text text) RETURNS text
462
+ LANGUAGE sql IMMUTABLE
463
+ AS $$
464
+ SELECT replace(replace(replace(coalesce(p_text, ''), '<', '&lt;'), '>', '&gt;'), '"', '&quot;');
465
+ $$;
466
+
467
+ CREATE FUNCTION public.plg_blog_safe_url(p_url text) RETURNS text
468
+ LANGUAGE plpgsql IMMUTABLE
469
+ AS $$
470
+ DECLARE
471
+ v text;
472
+ BEGIN
473
+ IF p_url IS NULL THEN RETURN NULL; END IF;
474
+ -- Whitespace and control characters go BEFORE the scheme test: \`java\\tscript:\`
475
+ -- is a working URL in every browser and would pass a naive prefix check.
476
+ v := regexp_replace(btrim(p_url), '[[:space:][:cntrl:]]', '', 'g');
477
+ IF v = '' THEN RETURN NULL; END IF;
478
+ -- Allowlist of schemes. \`data:\` is absent on purpose: a data URL is a way to
479
+ -- ship a whole document \u2014 including an SVG that scripts \u2014 inside an href.
480
+ IF v ~* '^(https?://|mailto:|/|#)' THEN
481
+ RETURN public.plg_blog_html_escape(v);
482
+ END IF;
483
+ RETURN NULL;
484
+ END;
485
+ $$;
486
+
487
+ CREATE FUNCTION public.plg_blog_sanitize_html(p_html text) RETURNS text
488
+ LANGUAGE plpgsql IMMUTABLE
489
+ SET search_path TO 'public'
490
+ AS $$
491
+ DECLARE
492
+ -- The allowlist is what an ARTICLE is made of. Layout tags (div, span,
493
+ -- section, main) are deliberately out: they carry nothing a blog post needs
494
+ -- and every one of them is a place to hang an attribute. Anything not listed
495
+ -- loses its tag and keeps its text, so a stripped wrapper degrades to prose
496
+ -- rather than to a hole in the article.
497
+ c_allowed constant text[] := ARRAY[
498
+ 'p','br','hr','h1','h2','h3','h4','h5','h6',
499
+ 'strong','b','em','i','u','s','sup','sub','code','pre','blockquote','q','cite',
500
+ 'ul','ol','li','dl','dt','dd',
501
+ 'a','img','figure','figcaption',
502
+ 'table','thead','tbody','tfoot','tr','th','td','caption'
503
+ ];
504
+ c_void constant text[] := ARRAY['br','hr','img'];
505
+ -- Elements whose CONTENT is code rather than text.
506
+ c_active constant text :=
507
+ 'script|style|iframe|object|embed|applet|form|noscript|svg|math|template|frameset|frame';
508
+ v text;
509
+ v_rest text;
510
+ v_out text := '';
511
+ v_lt int;
512
+ v_gt int;
513
+ v_tag text;
514
+ v_name text;
515
+ v_val text;
516
+ v_attrs text;
517
+ BEGIN
518
+ IF p_html IS NULL OR btrim(p_html) = '' THEN RETURN ''; END IF;
519
+ v := p_html;
520
+
521
+ -- Comments first: a conditional comment is executable markup in older
522
+ -- engines, and \`<!-- <script>\u2026 -->\` would otherwise reach the pass below
523
+ -- already commented out and survive it.
524
+ v := regexp_replace(v, '<!--.*?-->', '', 'g');
525
+ v := regexp_replace(v, '<!\\[CDATA\\[.*?\\]\\]>', '', 'g');
526
+ v := regexp_replace(v, '<\\?.*?\\?>', '', 'g');
527
+
528
+ -- Active elements go whole. Dropping only the tag would leave the script body
529
+ -- standing as visible page text \u2014 the article would read as vandalised even
530
+ -- though nothing executed.
531
+ -- Two Postgres regex traps, both of which fail OPEN and were caught by
532
+ -- running this against a container rather than by reading it:
533
+ -- \`\\y\`, not \`\\b\` \u2014 in a Postgres RE \`\\b\` is the BACKSPACE character, so
534
+ -- \`<script\\b\` matches nothing and the script body survives as page text.
535
+ -- Every quantifier non-greedy \u2014 a Postgres RE takes its greediness from its
536
+ -- FIRST quantifier, so one greedy \`\\s*\` at the front makes the trailing
537
+ -- \`.*?\` greedy too, and the first <script> swallows the article down to the
538
+ -- last closing tag in the document.
539
+ v := regexp_replace(v, '<\\s*?(' || c_active || ')\\y[^>]*?>.*?<\\s*?/\\s*?\\1\\s*?>', '', 'gi');
540
+ -- \u2026and the unbalanced halves of the same set, plus the document-level tags
541
+ -- that have no business inside an article body.
542
+ v := regexp_replace(
543
+ v,
544
+ '<\\s*/?\\s*(' || c_active || '|link|meta|base|title|head|body|html)\\y[^>]*>',
545
+ '', 'gi');
546
+
547
+ -- Walk the remaining tags. A rewrite rather than a filter: every tag that
548
+ -- comes out the other side was BUILT here from a name and an allowlisted
549
+ -- attribute, so nothing an attacker wrote is copied through by accident.
550
+ v_rest := v;
551
+ LOOP
552
+ v_lt := position('<' in v_rest);
553
+ IF v_lt = 0 THEN
554
+ v_out := v_out || v_rest;
555
+ EXIT;
556
+ END IF;
557
+ v_out := v_out || substr(v_rest, 1, v_lt - 1);
558
+ v_rest := substr(v_rest, v_lt);
559
+
560
+ v_gt := position('>' in v_rest);
561
+ IF v_gt = 0 THEN
562
+ -- An unterminated '<' is text. It is escaped so it cannot open a tag when
563
+ -- something is concatenated after this body.
564
+ v_out := v_out || replace(v_rest, '<', '&lt;');
565
+ EXIT;
566
+ END IF;
567
+ v_tag := substr(v_rest, 1, v_gt);
568
+ v_rest := substr(v_rest, v_gt + 1);
569
+
570
+ v_name := lower(coalesce(substring(v_tag from '^<\\s*/?\\s*([a-zA-Z][a-zA-Z0-9]*)'), ''));
571
+ IF v_name = '' OR NOT (v_name = ANY (c_allowed)) THEN
572
+ CONTINUE; -- tag dropped, its text kept
573
+ END IF;
574
+
575
+ IF v_tag ~ '^<\\s*/' THEN
576
+ IF NOT (v_name = ANY (c_void)) THEN v_out := v_out || '</' || v_name || '>'; END IF;
577
+ CONTINUE;
578
+ END IF;
579
+
580
+ -- Attributes are an allowlist too, and a per-tag one: \`href\` on <a>,
581
+ -- \`src\`/\`alt\` on <img>. Everything else \u2014 style, on*, srcset, class,
582
+ -- data-* \u2014 is dropped rather than inspected, because inspecting means
583
+ -- maintaining a list of what is dangerous, and that list is never finished.
584
+ v_attrs := '';
585
+ IF v_name = 'a' THEN
586
+ v_val := public.plg_blog_safe_url(public.plg_blog_html_attr(v_tag, 'href'));
587
+ IF v_val IS NOT NULL THEN
588
+ -- An external publisher's outbound links are not the tenant's
589
+ -- endorsement, and an unmarked link farm is precisely the SEO damage
590
+ -- this seam could otherwise cause on the tenant's own domain.
591
+ v_attrs := ' href="' || v_val || '" rel="nofollow noopener"';
592
+ END IF;
593
+ ELSIF v_name = 'img' THEN
594
+ v_val := public.plg_blog_safe_url(public.plg_blog_html_attr(v_tag, 'src'));
595
+ IF v_val IS NULL THEN CONTINUE; END IF; -- an <img> with no safe src is nothing
596
+ v_attrs := ' src="' || v_val || '" alt="'
597
+ || public.plg_blog_html_escape(public.plg_blog_html_attr(v_tag, 'alt')) || '"';
598
+ END IF;
599
+
600
+ v_out := v_out || '<' || v_name || v_attrs
601
+ || CASE WHEN v_name = ANY (c_void) THEN ' />' ELSE '>' END;
602
+ END LOOP;
603
+
604
+ RETURN v_out;
605
+ END;
606
+ $$;
607
+
608
+ CREATE FUNCTION public.plg_blog_slugify(p_text text) RETURNS text
609
+ LANGUAGE sql IMMUTABLE
610
+ AS $$
611
+ SELECT btrim(
612
+ regexp_replace(
613
+ translate(lower(coalesce(p_text, '')),
614
+ '\xE1\xE0\xE2\xE3\xE4\xE5\xE9\xE8\xEA\xEB\xED\xEC\xEE\xEF\xF3\xF2\xF4\xF5\xF6\xF8\xFA\xF9\xFB\xFC\xE7\xF1\xFD\xFF',
615
+ 'aaaaaaeeeeiiiioooooouuuucnyy'),
616
+ '[^a-z0-9]+', '-', 'g'),
617
+ '-');
618
+ $$;
619
+
620
+
621
+ -- \u2500\u2500 constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
622
+
623
+ ALTER TABLE ONLY public.plg_blog_categories
624
+ ADD CONSTRAINT plg_blog_categories_pkey PRIMARY KEY (id);
625
+
626
+ ALTER TABLE ONLY public.plg_blog_posts
627
+ ADD CONSTRAINT plg_blog_posts_pkey PRIMARY KEY (id);
628
+
629
+
630
+ -- \u2500\u2500 view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
631
+
632
+ CREATE VIEW public.v_public_blog_posts AS
633
+ SELECT p.id,
634
+ p.tenant_id,
635
+ p.slug,
636
+ p.title,
637
+ p.excerpt,
638
+ p.body,
639
+ COALESCE(c.name, ''::text) AS tag,
640
+ p.cover_image,
641
+ p.author_name,
642
+ p.author_role,
643
+ p.author_avatar_url,
644
+ p.author_bio,
645
+ p.read_time,
646
+ p.published_at,
647
+ p.meta_title,
648
+ p.meta_description,
649
+ p.canonical_url,
650
+ p.og_image,
651
+ p.noindex,
652
+ p.language,
653
+ p.tags
654
+ FROM (public.plg_blog_posts p
655
+ LEFT JOIN public.plg_blog_categories c ON ((c.id = p.category_id)))
656
+ WHERE (p.status = 'published'::text);
657
+
658
+
659
+ -- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
660
+
661
+ CREATE INDEX idx_plg_blog_categories_tenant ON public.plg_blog_categories USING btree (tenant_id);
662
+
663
+ CREATE INDEX idx_plg_blog_posts_category ON public.plg_blog_posts USING btree (category_id);
664
+
665
+ CREATE INDEX idx_plg_blog_posts_source ON public.plg_blog_posts USING btree (tenant_id, source, updated_at DESC);
666
+
667
+ CREATE INDEX idx_plg_blog_posts_status ON public.plg_blog_posts USING btree (tenant_id, status, published_at DESC);
668
+
669
+ CREATE INDEX idx_plg_blog_posts_tenant ON public.plg_blog_posts USING btree (tenant_id);
670
+
671
+ CREATE UNIQUE INDEX uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories USING btree (tenant_id, slug);
672
+
673
+ CREATE UNIQUE INDEX uq_plg_blog_posts_tenant_external_ref ON public.plg_blog_posts USING btree (tenant_id, external_ref) WHERE (external_ref IS NOT NULL);
674
+
675
+ CREATE UNIQUE INDEX uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts USING btree (tenant_id, slug);
676
+
677
+
678
+ -- \u2500\u2500 fk constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
679
+
680
+ ALTER TABLE ONLY public.plg_blog_categories
681
+ ADD CONSTRAINT plg_blog_categories_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
682
+
683
+ ALTER TABLE ONLY public.plg_blog_posts
684
+ ADD CONSTRAINT plg_blog_posts_author_member_id_fkey FOREIGN KEY (author_member_id) REFERENCES public.tenant_members(id) ON DELETE SET NULL;
685
+
686
+ ALTER TABLE ONLY public.plg_blog_posts
687
+ ADD CONSTRAINT plg_blog_posts_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL;
688
+
689
+ ALTER TABLE ONLY public.plg_blog_posts
690
+ ADD CONSTRAINT plg_blog_posts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
691
+
692
+
693
+ -- \u2500\u2500 trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
694
+
695
+ CREATE TRIGGER plg_blog_posts_emit_facts AFTER INSERT OR DELETE OR UPDATE ON public.plg_blog_posts FOR EACH ROW EXECUTE FUNCTION public.plg_blog_emit_post_event();
696
+
697
+
698
+ -- \u2500\u2500 row security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
699
+
700
+ ALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;
701
+
702
+ ALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;
703
+
704
+
705
+ -- \u2500\u2500 policy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
706
+
707
+ CREATE POLICY plg_blog_categories_delete ON public.plg_blog_categories FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
708
+
709
+ CREATE POLICY plg_blog_categories_insert ON public.plg_blog_categories FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
710
+
711
+ CREATE POLICY plg_blog_categories_select ON public.plg_blog_categories FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
712
+
713
+ CREATE POLICY plg_blog_categories_update ON public.plg_blog_categories FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
714
+
715
+ CREATE POLICY plg_blog_posts_delete ON public.plg_blog_posts FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
716
+
717
+ CREATE POLICY plg_blog_posts_insert ON public.plg_blog_posts FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
718
+
719
+ CREATE POLICY plg_blog_posts_select ON public.plg_blog_posts FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
720
+
721
+ CREATE POLICY plg_blog_posts_update ON public.plg_blog_posts FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));
722
+
723
+
724
+ -- \u2500\u2500 acl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
725
+
726
+ REVOKE ALL ON FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;
727
+ GRANT ALL ON FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;
728
+ GRANT ALL ON FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;
729
+
730
+ REVOKE ALL ON FUNCTION public.blog_upsert_external_post(p_payload jsonb) FROM PUBLIC;
731
+ GRANT ALL ON FUNCTION public.blog_upsert_external_post(p_payload jsonb) TO authenticated;
732
+ GRANT ALL ON FUNCTION public.blog_upsert_external_post(p_payload jsonb) TO service_role;
733
+
734
+ REVOKE ALL ON FUNCTION public.plg_blog_emit_post_event() FROM PUBLIC;
735
+ GRANT ALL ON FUNCTION public.plg_blog_emit_post_event() TO authenticated;
736
+ GRANT ALL ON FUNCTION public.plg_blog_emit_post_event() TO service_role;
737
+
738
+ REVOKE ALL ON FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) FROM PUBLIC;
739
+ GRANT ALL ON FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) TO authenticated;
740
+ GRANT ALL ON FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) TO service_role;
741
+
742
+ REVOKE ALL ON FUNCTION public.plg_blog_html_escape(p_text text) FROM PUBLIC;
743
+ GRANT ALL ON FUNCTION public.plg_blog_html_escape(p_text text) TO authenticated;
744
+ GRANT ALL ON FUNCTION public.plg_blog_html_escape(p_text text) TO service_role;
745
+
746
+ REVOKE ALL ON FUNCTION public.plg_blog_safe_url(p_url text) FROM PUBLIC;
747
+ GRANT ALL ON FUNCTION public.plg_blog_safe_url(p_url text) TO authenticated;
748
+ GRANT ALL ON FUNCTION public.plg_blog_safe_url(p_url text) TO service_role;
749
+
750
+ REVOKE ALL ON FUNCTION public.plg_blog_sanitize_html(p_html text) FROM PUBLIC;
751
+ GRANT ALL ON FUNCTION public.plg_blog_sanitize_html(p_html text) TO authenticated;
752
+ GRANT ALL ON FUNCTION public.plg_blog_sanitize_html(p_html text) TO service_role;
753
+
754
+ REVOKE ALL ON FUNCTION public.plg_blog_slugify(p_text text) FROM PUBLIC;
755
+ GRANT ALL ON FUNCTION public.plg_blog_slugify(p_text text) TO authenticated;
756
+ GRANT ALL ON FUNCTION public.plg_blog_slugify(p_text text) TO service_role;
757
+
758
+ GRANT MAINTAIN ON TABLE public.plg_blog_categories TO anon;
759
+ GRANT ALL ON TABLE public.plg_blog_categories TO authenticated;
760
+ GRANT ALL ON TABLE public.plg_blog_categories TO service_role;
761
+
762
+ GRANT MAINTAIN ON TABLE public.plg_blog_posts TO anon;
763
+ GRANT ALL ON TABLE public.plg_blog_posts TO authenticated;
764
+ GRANT ALL ON TABLE public.plg_blog_posts TO service_role;
765
+
766
+ GRANT SELECT,MAINTAIN ON TABLE public.v_public_blog_posts TO anon;
767
+ GRANT ALL ON TABLE public.v_public_blog_posts TO authenticated;
768
+ GRANT ALL ON TABLE public.v_public_blog_posts TO service_role;
769
+
770
+
771
+ -- \u2500\u2500 default privileges: restore \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
772
+ -- Put back, so the NEXT thing installed inherits what the chain had.
773
+
774
+ DO $dp$ BEGIN
775
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
776
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
777
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
778
+ END $dp$;
779
+
780
+ DO $dp$ BEGIN
781
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
782
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
783
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
784
+ END $dp$;
785
+
786
+ DO $dp$ BEGIN
787
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
788
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
789
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
790
+ END $dp$;
791
+
792
+ DO $dp$ BEGIN
793
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
794
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
795
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
796
+ END $dp$;
797
+
798
+ DO $dp$ BEGIN
799
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
800
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
801
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
802
+ END $dp$;
803
+
804
+ DO $dp$ BEGIN
805
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
806
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
807
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
808
+ END $dp$;
809
+
810
+ DO $dp$ BEGIN
811
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
812
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
813
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
814
+ END $dp$;
815
+
816
+ DO $dp$ BEGIN
817
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
818
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
819
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
820
+ END $dp$;
821
+
822
+ DO $dp$ BEGIN
823
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
824
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
825
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
826
+ END $dp$;
827
+
828
+ DO $dp$ BEGIN
829
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
830
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
831
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
832
+ END $dp$;
833
+
834
+ DO $dp$ BEGIN
835
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
836
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
837
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
838
+ END $dp$;
839
+
840
+ DO $dp$ BEGIN
841
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
842
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
843
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
844
+ END $dp$;
845
+
846
+ DO $dp$ BEGIN
847
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
848
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
849
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
850
+ END $dp$;
851
+
852
+ DO $dp$ BEGIN
853
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
854
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
855
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
856
+ END $dp$;
857
+
858
+ DO $dp$ BEGIN
859
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
860
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
861
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
862
+ END $dp$;
863
+
864
+ DO $dp$ BEGIN
865
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
866
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
867
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
868
+ END $dp$;
869
+
870
+ DO $dp$ BEGIN
871
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
872
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
873
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
874
+ END $dp$;
875
+
876
+ DO $dp$ BEGIN
877
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
878
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
879
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
880
+ END $dp$;
881
+
882
+ DO $dp$ BEGIN
883
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
884
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
885
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
886
+ END $dp$;
887
+
888
+ DO $dp$ BEGIN
889
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
890
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
891
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
892
+ END $dp$;
893
+
894
+ DO $dp$ BEGIN
895
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
896
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
897
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
898
+ END $dp$;
899
+
900
+ DO $dp$ BEGIN
901
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
902
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
903
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
904
+ END $dp$;
905
+
906
+ DO $dp$ BEGIN
907
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
908
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
909
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
910
+ END $dp$;
911
+
912
+ DO $dp$ BEGIN
913
+ EXECUTE $stmt$--
914
+ -- PostgreSQL database dump complete
915
+ --$stmt$;
916
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
917
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
918
+ -- PostgreSQL database dump complete
919
+ --$stmt$;
920
+ END $dp$;
921
+ `;
922
+ var MIGRATION_001_THE_AUTHOR_IS_A_MEMBERSHIP = `-- ============================================================================
923
+ -- 001_the_author_is_a_membership \u2014 the FK and the lookup leave the projection
924
+ -- (#214 / #352).
925
+ --
926
+ -- plg_blog_posts.author_member_id pointed at public.tenant_members(id), which
927
+ -- is a COMPAT PROJECTION of app.memberships maintained by triggers \u2014 so a real
928
+ -- foreign key was resting on a derived table. That is the reason the projection
929
+ -- could not be dropped, and it is a stronger reason than "a screen reads it":
930
+ -- Postgres refuses to drop a table another table references.
931
+ --
932
+ -- The move is safe precisely because the projection's own comment says the ids
933
+ -- are the same ("COMPAT PROJECTION of app.memberships (#134): same ids"), so
934
+ -- every existing author_member_id already names an app.memberships row.
935
+ -- ============================================================================
936
+
937
+ ALTER TABLE public.plg_blog_posts DROP CONSTRAINT IF EXISTS plg_blog_posts_author_member_id_fkey;
938
+ ALTER TABLE public.plg_blog_posts
939
+ ADD CONSTRAINT plg_blog_posts_author_member_id_fkey
940
+ FOREIGN KEY (author_member_id) REFERENCES app.memberships(id) ON DELETE SET NULL;
941
+
942
+ CREATE OR REPLACE FUNCTION public.blog_upsert_external_post(p_payload jsonb) RETURNS jsonb
943
+ LANGUAGE plpgsql SECURITY DEFINER
944
+ SET search_path TO 'public'
945
+ AS $$
946
+ DECLARE
947
+ v_tenants uuid[];
948
+ v_tenant uuid;
949
+ v_claimed uuid;
950
+ v_ref text;
951
+ v_title text;
952
+ v_base text;
953
+ v_slug text;
954
+ v_status text;
955
+ v_source text;
956
+ v_lang text;
957
+ v_body text;
958
+ v_excerpt text;
959
+ v_tags text[];
960
+ v_author uuid;
961
+ v_pub timestamptz;
962
+ v_cover text;
963
+ v_existing_id uuid;
964
+ v_existing_pub timestamptz;
965
+ v_existing_slug text;
966
+ v_n int := 1;
967
+ v_id uuid;
968
+ v_created boolean := false;
969
+ BEGIN
970
+ -- \u2500\u2500 tenant: from the credential, never from the payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
971
+ -- A tenant id the caller supplies is how one tenant writes into another's
972
+ -- blog, and the caller is a machine outside our trust boundary. The only
973
+ -- statement about identity we can believe is the one the credential makes.
974
+ SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
975
+ IF v_tenants IS NULL OR array_length(v_tenants, 1) <> 1 THEN
976
+ RAISE EXCEPTION
977
+ 'blog_upsert_external_post: the caller must present a credential scoped to exactly one tenant'
978
+ USING ERRCODE = 'insufficient_privilege';
979
+ END IF;
980
+ v_tenant := v_tenants[1];
981
+
982
+ -- Refusing beats ignoring: a publisher that believes it is writing into
983
+ -- another tenant must be told so, not silently redirected into this one.
984
+ IF nullif(btrim(p_payload->>'tenant_id'), '') IS NOT NULL THEN
985
+ BEGIN
986
+ v_claimed := (p_payload->>'tenant_id')::uuid;
987
+ EXCEPTION WHEN OTHERS THEN
988
+ v_claimed := NULL;
989
+ END;
990
+ IF v_claimed IS DISTINCT FROM v_tenant THEN
991
+ RAISE EXCEPTION 'blog_upsert_external_post: payload names another tenant; the tenant comes from the credential'
992
+ USING ERRCODE = 'insufficient_privilege';
993
+ END IF;
994
+ END IF;
995
+
996
+ -- \u2500\u2500 the article \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
997
+ v_ref := nullif(btrim(p_payload->>'external_ref'), '');
998
+ v_title := nullif(btrim(p_payload->>'title'), '');
999
+ IF v_title IS NULL THEN
1000
+ RAISE EXCEPTION 'blog_upsert_external_post: title is required'
1001
+ USING ERRCODE = 'invalid_parameter_value';
1002
+ END IF;
1003
+
1004
+ v_status := lower(coalesce(nullif(btrim(p_payload->>'status'), ''), 'draft'));
1005
+ IF v_status NOT IN ('draft', 'published') THEN
1006
+ RAISE EXCEPTION 'blog_upsert_external_post: unknown status %; expected draft or published', v_status
1007
+ USING ERRCODE = 'invalid_parameter_value';
1008
+ END IF;
1009
+
1010
+ -- Never taken as 'admin' by default: a post that arrived through this door is
1011
+ -- external by definition, and mislabelling it is what makes a connector echo
1012
+ -- it back to the system that sent it.
1013
+ v_source := lower(coalesce(nullif(btrim(p_payload->>'source'), ''), 'external'));
1014
+ v_lang := nullif(btrim(p_payload->>'language'), '');
1015
+ v_body := public.plg_blog_sanitize_html(p_payload->>'body_html');
1016
+ v_excerpt := left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500);
1017
+
1018
+ IF jsonb_typeof(p_payload->'tags') = 'array' THEN
1019
+ SELECT array_agg(btrim(x)) INTO v_tags
1020
+ FROM jsonb_array_elements_text(p_payload->'tags') AS x
1021
+ WHERE btrim(x) <> '';
1022
+ END IF;
1023
+
1024
+ -- An author id is a claim about a person in THIS tenant. Unverified, it would
1025
+ -- let an external publisher put a real employee's name on anything it writes.
1026
+ IF nullif(btrim(p_payload->>'author_member_id'), '') IS NOT NULL THEN
1027
+ BEGIN
1028
+ SELECT tm.id INTO v_author
1029
+ FROM app.memberships tm
1030
+ WHERE tm.tenant_id = v_tenant AND tm.id = (p_payload->>'author_member_id')::uuid;
1031
+ EXCEPTION WHEN OTHERS THEN
1032
+ v_author := NULL;
1033
+ END;
1034
+ END IF;
1035
+
1036
+ v_cover := public.plg_blog_safe_url(
1037
+ coalesce(nullif(btrim(p_payload->>'cover_image'), ''), nullif(btrim(p_payload->>'og_image'), '')));
1038
+
1039
+ -- \u2500\u2500 identity + slug \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1040
+ IF v_ref IS NOT NULL THEN
1041
+ SELECT p.id, p.published_at, p.slug INTO v_existing_id, v_existing_pub, v_existing_slug
1042
+ FROM public.plg_blog_posts p
1043
+ WHERE p.tenant_id = v_tenant AND p.external_ref = v_ref;
1044
+ END IF;
1045
+
1046
+ -- On re-delivery the existing slug WINS over one re-derived from the title.
1047
+ -- A published post's slug is its URL: silently re-slugging it because the
1048
+ -- publisher fixed a typo in the headline breaks every inbound link \u2014 on an
1049
+ -- SEO product's own articles, of all things. Changing it stays possible, but
1050
+ -- only by saying so explicitly in \`slug\`.
1051
+ v_base := coalesce(
1052
+ nullif(public.plg_blog_slugify(p_payload->>'slug'), ''),
1053
+ nullif(v_existing_slug, ''),
1054
+ nullif(public.plg_blog_slugify(v_title), ''),
1055
+ 'post-' || left(md5(coalesce(v_ref, gen_random_uuid()::text)), 8));
1056
+ v_base := left(v_base, 120);
1057
+
1058
+ -- Slug is unique per tenant (001). Two different articles that slugify the
1059
+ -- same must not make the second delivery fail \u2014 a suffixed slug is a smaller
1060
+ -- surprise than a rejected publish.
1061
+ v_slug := v_base;
1062
+ WHILE EXISTS (
1063
+ SELECT 1 FROM public.plg_blog_posts p
1064
+ WHERE p.tenant_id = v_tenant AND p.slug = v_slug
1065
+ AND (v_existing_id IS NULL OR p.id <> v_existing_id)
1066
+ ) LOOP
1067
+ v_n := v_n + 1;
1068
+ IF v_n > 50 THEN
1069
+ RAISE EXCEPTION 'blog_upsert_external_post: cannot derive a free slug from %', v_base
1070
+ USING ERRCODE = 'invalid_parameter_value';
1071
+ END IF;
1072
+ v_slug := left(v_base, 112) || '-' || v_n;
1073
+ END LOOP;
1074
+
1075
+ BEGIN
1076
+ v_pub := nullif(btrim(p_payload->>'published_at'), '')::timestamptz;
1077
+ EXCEPTION WHEN OTHERS THEN
1078
+ v_pub := NULL;
1079
+ END;
1080
+ -- A post that goes back to draft keeps the date it first went out; the public
1081
+ -- view filters on status, so nothing leaks, and the history stays true.
1082
+ v_pub := coalesce(v_pub, v_existing_pub, CASE WHEN v_status = 'published' THEN now() END);
1083
+
1084
+ -- \u2500\u2500 write \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1085
+ IF v_existing_id IS NOT NULL THEN
1086
+ UPDATE public.plg_blog_posts SET
1087
+ slug = v_slug,
1088
+ title = v_title,
1089
+ excerpt = v_excerpt,
1090
+ body = v_body,
1091
+ status = v_status,
1092
+ published_at = v_pub,
1093
+ source = v_source,
1094
+ language = v_lang,
1095
+ tags = v_tags,
1096
+ author_member_id = coalesce(v_author, author_member_id),
1097
+ cover_image = coalesce(v_cover, cover_image),
1098
+ meta_title = nullif(btrim(p_payload->>'meta_title'), ''),
1099
+ meta_description = nullif(btrim(p_payload->>'meta_description'), ''),
1100
+ canonical_url = public.plg_blog_safe_url(p_payload->>'canonical_url'),
1101
+ og_image = public.plg_blog_safe_url(p_payload->>'og_image'),
1102
+ noindex = coalesce((p_payload->>'noindex')::boolean, false),
1103
+ updated_at = now()
1104
+ WHERE id = v_existing_id;
1105
+ v_id := v_existing_id;
1106
+ ELSE
1107
+ -- ON CONFLICT on the partial index, not just the SELECT above: two
1108
+ -- deliveries of the same article racing each other would both find nothing
1109
+ -- and both insert. The index is what makes the second one an update.
1110
+ INSERT INTO public.plg_blog_posts (
1111
+ tenant_id, slug, title, excerpt, body, status, published_at,
1112
+ external_ref, source, language, tags, author_member_id, cover_image,
1113
+ meta_title, meta_description, canonical_url, og_image, noindex, updated_at)
1114
+ VALUES (
1115
+ v_tenant, v_slug, v_title, v_excerpt, v_body, v_status, v_pub,
1116
+ v_ref, v_source, v_lang, v_tags, v_author, v_cover,
1117
+ nullif(btrim(p_payload->>'meta_title'), ''),
1118
+ nullif(btrim(p_payload->>'meta_description'), ''),
1119
+ public.plg_blog_safe_url(p_payload->>'canonical_url'),
1120
+ public.plg_blog_safe_url(p_payload->>'og_image'),
1121
+ coalesce((p_payload->>'noindex')::boolean, false),
1122
+ now())
1123
+ ON CONFLICT (tenant_id, external_ref) WHERE external_ref IS NOT NULL
1124
+ DO UPDATE SET
1125
+ title = excluded.title,
1126
+ excerpt = excluded.excerpt,
1127
+ body = excluded.body,
1128
+ status = excluded.status,
1129
+ published_at = coalesce(excluded.published_at, plg_blog_posts.published_at),
1130
+ source = excluded.source,
1131
+ language = excluded.language,
1132
+ tags = excluded.tags,
1133
+ cover_image = coalesce(excluded.cover_image, plg_blog_posts.cover_image),
1134
+ meta_title = excluded.meta_title,
1135
+ meta_description = excluded.meta_description,
1136
+ canonical_url = excluded.canonical_url,
1137
+ og_image = excluded.og_image,
1138
+ noindex = excluded.noindex,
1139
+ updated_at = now()
1140
+ RETURNING id, (xmax = 0) INTO v_id, v_created;
1141
+ END IF;
1142
+
1143
+ RETURN jsonb_build_object(
1144
+ 'ok', true,
1145
+ 'id', v_id,
1146
+ 'slug', v_slug,
1147
+ 'status', v_status,
1148
+ 'source', v_source,
1149
+ 'external_ref', v_ref,
1150
+ 'created', coalesce(v_created, false));
1151
+ END;
1152
+ $$;`;
1153
+ var MIGRATIONS = [
1154
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
1155
+ { id: "001_the_author_is_a_membership", sql: MIGRATION_001_THE_AUTHOR_IS_A_MEMBERSHIP }
1156
+ ];
1157
+
1158
+ export { MIGRATIONS, MIGRATION_000_BASELINE, MIGRATION_001_THE_AUTHOR_IS_A_MEMBERSHIP };
1159
+ //# sourceMappingURL=main.js.map
1160
+ //# sourceMappingURL=main.js.map