@fayz-ai/plugin-blog 0.12.6 → 0.12.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -113,219 +113,474 @@ function createSupabaseBlogProvider(options) {
113
113
  }
114
114
 
115
115
  // src/migrations/index.ts
116
- var MIGRATION_001_BLOG = `-- ============================================================================
117
- -- plugin-blog 001: blog content model + public (anon) read surface
116
+ var MIGRATION_000_BASELINE = `-- ============================================================================
117
+ -- plugins/plugin-blog/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.
118
118
  --
119
- -- Backs the blog backoffice (managed from the marketing plugin's "Blog" tab) and
120
- -- the public website surface (/blog, /blog/:slug). Prefix: plg_blog_.
121
- -- \xA71 plg_blog_categories \u2014 first-class category (name/slug/description)
122
- -- \xA72 plg_blog_posts \u2014 articles; category_id FK, author byline, status
123
- -- \xA73 RLS: authenticated tenant CRUD on both tables (the backoffice)
124
- -- \xA74 v_public_blog_posts \u2014 anon-readable view of PUBLISHED posts (the site)
119
+ -- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:
120
+ -- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs
121
+ -- prove the result still matches.
125
122
  --
126
- -- Anon-access design mirrors plugin-agenda's public booking surface: the public
127
- -- website runs with the publishable key and no session, so canonical tenant RLS
128
- -- yields nothing. Anon read is granted ONLY through the column-whitelisted,
129
- -- published-only view (\xA74); the front provider filters it by tenant_id.
130
- -- Idempotent + safe to re-run.
123
+ -- This replaced 2 migration files. They are kept, unapplied, in
124
+ -- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no
125
+ -- longer worth replaying.
126
+ --
127
+ -- Object order is fixed rather than dependency-sorted: schemas, types,
128
+ -- sequences, tables, defaults, functions, constraints, views, indexes, foreign
129
+ -- keys, triggers, row security, policies, grants. Dependencies BETWEEN units
130
+ -- are carried by the order packages/db/chain.json declares.
131
131
  -- ============================================================================
132
132
 
133
- -- \xA71 \u2014 categories
134
- CREATE TABLE IF NOT EXISTS public.plg_blog_categories (
135
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
136
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
137
- name text NOT NULL,
138
- slug text NOT NULL,
139
- description text,
140
- created_at timestamptz NOT NULL DEFAULT now(),
141
- updated_at timestamptz NOT NULL DEFAULT now()
142
- );
143
- ALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;
144
- CREATE INDEX IF NOT EXISTS idx_plg_blog_categories_tenant ON public.plg_blog_categories(tenant_id);
145
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories(tenant_id, slug);
146
-
147
- -- \xA72 \u2014 posts
148
- CREATE TABLE IF NOT EXISTS public.plg_blog_posts (
149
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
150
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
151
- category_id uuid REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL,
152
- slug text NOT NULL,
153
- title text NOT NULL,
154
- excerpt text NOT NULL DEFAULT '',
155
- body text NOT NULL DEFAULT '',
156
- cover_image text,
157
- author_name text,
158
- author_role text,
159
- author_avatar_url text,
160
- author_bio text,
161
- status text NOT NULL DEFAULT 'draft'
162
- CHECK (status IN ('draft', 'published')),
163
- read_time text,
164
- published_at timestamptz,
165
- created_at timestamptz NOT NULL DEFAULT now(),
166
- updated_at timestamptz NOT NULL DEFAULT now()
167
- );
168
- ALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;
169
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_tenant ON public.plg_blog_posts(tenant_id);
170
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_category ON public.plg_blog_posts(category_id);
171
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_status ON public.plg_blog_posts(tenant_id, status, published_at DESC);
172
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts(tenant_id, slug);
173
-
174
- -- \xA73 \u2014 RLS: authenticated tenant CRUD (the backoffice writes here)
175
- DROP POLICY IF EXISTS plg_blog_categories_select ON public.plg_blog_categories;
176
- DROP POLICY IF EXISTS plg_blog_categories_insert ON public.plg_blog_categories;
177
- DROP POLICY IF EXISTS plg_blog_categories_update ON public.plg_blog_categories;
178
- DROP POLICY IF EXISTS plg_blog_categories_delete ON public.plg_blog_categories;
179
- CREATE POLICY plg_blog_categories_select ON public.plg_blog_categories FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
180
- 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()));
181
- CREATE POLICY plg_blog_categories_update ON public.plg_blog_categories FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
182
- CREATE POLICY plg_blog_categories_delete ON public.plg_blog_categories FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
183
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_categories TO authenticated;
184
-
185
- DROP POLICY IF EXISTS plg_blog_posts_select ON public.plg_blog_posts;
186
- DROP POLICY IF EXISTS plg_blog_posts_insert ON public.plg_blog_posts;
187
- DROP POLICY IF EXISTS plg_blog_posts_update ON public.plg_blog_posts;
188
- DROP POLICY IF EXISTS plg_blog_posts_delete ON public.plg_blog_posts;
189
- CREATE POLICY plg_blog_posts_select ON public.plg_blog_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
190
- 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()));
191
- CREATE POLICY plg_blog_posts_update ON public.plg_blog_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
192
- CREATE POLICY plg_blog_posts_delete ON public.plg_blog_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
193
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_posts TO authenticated;
194
-
195
- -- \xA74 \u2014 anon-readable public catalog (owner-rights view = deliberate, whitelisted,
196
- -- PUBLISHED only). The front provider filters by tenant_id. Category name is
197
- -- surfaced as \`tag\` so the website's BlogPost shape stays unchanged.
198
- -- Dropped, not replaced: a later file widens this view and CREATE OR REPLACE
199
- -- cannot narrow a live one back down ("cannot drop columns from view"), which is
200
- -- what a re-run of this file is asking for. 143 carries any dependent across.
201
- --
202
- -- This is one of the three anon-facing views 043 deliberately left alone, so it
203
- -- comes back WITHOUT security_invoker: flipping it would make it resolve as
204
- -- \`anon\` and the public blog would go dark.
205
- SELECT app.detach_view_dependents('public.v_public_blog_posts');
206
- DROP VIEW IF EXISTS public.v_public_blog_posts;
207
- CREATE OR REPLACE VIEW public.v_public_blog_posts AS
208
- SELECT
209
- p.id,
210
- p.tenant_id,
211
- p.slug,
212
- p.title,
213
- p.excerpt,
214
- p.body,
215
- COALESCE(c.name, '') AS tag,
216
- p.cover_image,
217
- p.author_name,
218
- p.author_role,
219
- p.author_avatar_url,
220
- p.author_bio,
221
- p.read_time,
222
- p.published_at
223
- FROM public.plg_blog_posts p
224
- LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
225
- WHERE p.status = 'published';
226
-
227
- GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
228
-
229
- SELECT app.reattach_view_dependents();
230
- `;
231
- var MIGRATION_002_EXTERNAL_POSTS = `-- ============================================================================
232
- -- plugin-blog 002: the ingest seam \u2014 per-post SEO, external identity, and the
233
- -- single function an outside publisher passes through.
234
- --
235
- -- 001 gave the blog a content model written by one hand: the backoffice, behind
236
- -- a session, through RLS. RankLayer publishes INTO the tenant instead, so the
237
- -- write arrives from a machine we do not run, carrying HTML we did not author
238
- -- and an identity that lives in someone else's database. Three things follow,
239
- -- and each is a section here.
240
- --
241
- -- \xA71 columns per-post SEO + the external identity (external_ref, source\u2026)
242
- -- \xA72 identity partial unique index on (tenant_id, external_ref)
243
- -- \xA73 sanitiser plg_blog_sanitize_html \u2014 a tag allowlist, in SQL
244
- -- \xA74 ingest blog_upsert_external_post(p_payload jsonb)
245
- -- \xA75 agent agent_blog_draft_post \u2014 the assistant's write, guarded
246
- -- \xA76 facts blog.post.{created,updated,published,deleted}
247
- -- \xA77 view v_public_blog_posts extended with the SEO the site renders
248
- --
249
- -- Additive and idempotent: no table is created, no policy is rewritten, the
250
- -- existing tenant RLS from 001 already covers every column added here.
251
- -- ============================================================================
133
+ -- A LANGUAGE sql function binds its body at CREATE time, and 570 functions
134
+ -- sorted by name are not sorted by who calls whom. This is what lets them load
135
+ -- in any order; every body is still compiled on first call, so a genuinely
136
+ -- broken reference surfaces then rather than never. Session-scoped.
137
+ SET check_function_bodies = false;
138
+
139
+
140
+
141
+ -- \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
142
+ -- Cleared BEFORE anything is created, so every object below gets the ACL the
143
+ -- dump describes rather than the image's defaults on top of it. Restored at
144
+ -- the end of this file.
145
+
146
+ DO $dp$ BEGIN
147
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
148
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
149
+ 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$;
150
+ END $dp$;
151
+
152
+ DO $dp$ BEGIN
153
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;
154
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
155
+ 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$;
156
+ END $dp$;
157
+
158
+ DO $dp$ BEGIN
159
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
160
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
161
+ 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$;
162
+ END $dp$;
163
+
164
+ DO $dp$ BEGIN
165
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;
166
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
167
+ 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$;
168
+ END $dp$;
169
+
170
+ DO $dp$ BEGIN
171
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
172
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
173
+ 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$;
174
+ END $dp$;
175
+
176
+ DO $dp$ BEGIN
177
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;
178
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
179
+ 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$;
180
+ END $dp$;
181
+
182
+
183
+ -- \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
184
+
185
+ CREATE TABLE public.plg_blog_categories (
186
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
187
+ tenant_id uuid NOT NULL,
188
+ name text NOT NULL,
189
+ slug text NOT NULL,
190
+ description text,
191
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
192
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
193
+ );
194
+
195
+ CREATE TABLE public.plg_blog_posts (
196
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
197
+ tenant_id uuid NOT NULL,
198
+ category_id uuid,
199
+ slug text NOT NULL,
200
+ title text NOT NULL,
201
+ excerpt text DEFAULT ''::text NOT NULL,
202
+ body text DEFAULT ''::text NOT NULL,
203
+ cover_image text,
204
+ author_name text,
205
+ author_role text,
206
+ author_avatar_url text,
207
+ author_bio text,
208
+ status text DEFAULT 'draft'::text NOT NULL,
209
+ read_time text,
210
+ published_at timestamp with time zone,
211
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
212
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
213
+ meta_title text,
214
+ meta_description text,
215
+ canonical_url text,
216
+ og_image text,
217
+ noindex boolean DEFAULT false NOT NULL,
218
+ external_ref text,
219
+ source text DEFAULT 'admin'::text NOT NULL,
220
+ language text,
221
+ tags text[],
222
+ author_member_id uuid,
223
+ CONSTRAINT plg_blog_posts_status_check CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))
224
+ );
225
+
226
+
227
+ -- \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
228
+
229
+ CREATE FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb
230
+ LANGUAGE plpgsql SECURITY DEFINER
231
+ SET search_path TO 'public'
232
+ AS $$
233
+ DECLARE
234
+ v_denial jsonb;
235
+ v_used int;
236
+ v_title text;
237
+ v_base text;
238
+ v_slug text;
239
+ v_n int := 1;
240
+ v_id uuid;
241
+ BEGIN
242
+ v_title := nullif(btrim(p_payload->>'title'), '');
243
+ IF v_title IS NULL THEN
244
+ RETURN jsonb_build_object('ok', false, 'error', 'title is required to draft a post');
245
+ END IF;
246
+
247
+ SELECT count(*) INTO v_used FROM public.plg_blog_posts p WHERE p.tenant_id = p_tenant_id;
248
+ v_denial := public.agent_guard(p_tenant_id, p_actor_user_id, 'blog', 'create', 'blog_posts', v_used, 1);
249
+ IF v_denial IS NOT NULL THEN
250
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
251
+ END IF;
252
+
253
+ v_base := coalesce(nullif(public.plg_blog_slugify(v_title), ''), 'post-' || left(md5(v_title), 8));
254
+ v_slug := left(v_base, 120);
255
+ WHILE EXISTS (SELECT 1 FROM public.plg_blog_posts p
256
+ WHERE p.tenant_id = p_tenant_id AND p.slug = v_slug) LOOP
257
+ v_n := v_n + 1;
258
+ IF v_n > 50 THEN
259
+ RETURN jsonb_build_object('ok', false, 'error', format('cannot derive a free slug from %L', v_base));
260
+ END IF;
261
+ v_slug := left(v_base, 112) || '-' || v_n;
262
+ END LOOP;
263
+
264
+ INSERT INTO public.plg_blog_posts (
265
+ tenant_id, slug, title, excerpt, body, status, source, language, tags, updated_at)
266
+ VALUES (
267
+ p_tenant_id, v_slug, v_title,
268
+ left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500),
269
+ -- Same sanitiser as the external door: the model's output is not more
270
+ -- trustworthy than a partner's, it is just closer to home.
271
+ public.plg_blog_sanitize_html(p_payload->>'body_html'),
272
+ 'draft', 'agent',
273
+ nullif(btrim(p_payload->>'language'), ''),
274
+ CASE WHEN jsonb_typeof(p_payload->'tags') = 'array'
275
+ THEN ARRAY(SELECT btrim(x) FROM jsonb_array_elements_text(p_payload->'tags') AS x WHERE btrim(x) <> '')
276
+ END,
277
+ now())
278
+ RETURNING id INTO v_id;
279
+
280
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
281
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.draftBlogPost', 'blog_post', v_id::text,
282
+ jsonb_build_object('title', v_title, 'slug', v_slug));
283
+
284
+ RETURN jsonb_build_object(
285
+ 'ok', true,
286
+ 'id', v_id,
287
+ 'record', jsonb_build_object(
288
+ 'ref', jsonb_build_object('id', v_id, 'resource', 'plg_blog_posts', 'entityKey', 'blog:posts'),
289
+ 'name', v_title,
290
+ 'slug', v_slug,
291
+ 'status', 'draft'));
292
+ END;
293
+ $$;
294
+
295
+ CREATE FUNCTION public.blog_upsert_external_post(p_payload jsonb) RETURNS jsonb
296
+ LANGUAGE plpgsql SECURITY DEFINER
297
+ SET search_path TO 'public'
298
+ AS $$
299
+ DECLARE
300
+ v_tenants uuid[];
301
+ v_tenant uuid;
302
+ v_claimed uuid;
303
+ v_ref text;
304
+ v_title text;
305
+ v_base text;
306
+ v_slug text;
307
+ v_status text;
308
+ v_source text;
309
+ v_lang text;
310
+ v_body text;
311
+ v_excerpt text;
312
+ v_tags text[];
313
+ v_author uuid;
314
+ v_pub timestamptz;
315
+ v_cover text;
316
+ v_existing_id uuid;
317
+ v_existing_pub timestamptz;
318
+ v_existing_slug text;
319
+ v_n int := 1;
320
+ v_id uuid;
321
+ v_created boolean := false;
322
+ BEGIN
323
+ -- \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
324
+ -- A tenant id the caller supplies is how one tenant writes into another's
325
+ -- blog, and the caller is a machine outside our trust boundary. The only
326
+ -- statement about identity we can believe is the one the credential makes.
327
+ SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
328
+ IF v_tenants IS NULL OR array_length(v_tenants, 1) <> 1 THEN
329
+ RAISE EXCEPTION
330
+ 'blog_upsert_external_post: the caller must present a credential scoped to exactly one tenant'
331
+ USING ERRCODE = 'insufficient_privilege';
332
+ END IF;
333
+ v_tenant := v_tenants[1];
334
+
335
+ -- Refusing beats ignoring: a publisher that believes it is writing into
336
+ -- another tenant must be told so, not silently redirected into this one.
337
+ IF nullif(btrim(p_payload->>'tenant_id'), '') IS NOT NULL THEN
338
+ BEGIN
339
+ v_claimed := (p_payload->>'tenant_id')::uuid;
340
+ EXCEPTION WHEN OTHERS THEN
341
+ v_claimed := NULL;
342
+ END;
343
+ IF v_claimed IS DISTINCT FROM v_tenant THEN
344
+ RAISE EXCEPTION 'blog_upsert_external_post: payload names another tenant; the tenant comes from the credential'
345
+ USING ERRCODE = 'insufficient_privilege';
346
+ END IF;
347
+ END IF;
348
+
349
+ -- \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
350
+ v_ref := nullif(btrim(p_payload->>'external_ref'), '');
351
+ v_title := nullif(btrim(p_payload->>'title'), '');
352
+ IF v_title IS NULL THEN
353
+ RAISE EXCEPTION 'blog_upsert_external_post: title is required'
354
+ USING ERRCODE = 'invalid_parameter_value';
355
+ END IF;
356
+
357
+ v_status := lower(coalesce(nullif(btrim(p_payload->>'status'), ''), 'draft'));
358
+ IF v_status NOT IN ('draft', 'published') THEN
359
+ RAISE EXCEPTION 'blog_upsert_external_post: unknown status %; expected draft or published', v_status
360
+ USING ERRCODE = 'invalid_parameter_value';
361
+ END IF;
362
+
363
+ -- Never taken as 'admin' by default: a post that arrived through this door is
364
+ -- external by definition, and mislabelling it is what makes a connector echo
365
+ -- it back to the system that sent it.
366
+ v_source := lower(coalesce(nullif(btrim(p_payload->>'source'), ''), 'external'));
367
+ v_lang := nullif(btrim(p_payload->>'language'), '');
368
+ v_body := public.plg_blog_sanitize_html(p_payload->>'body_html');
369
+ v_excerpt := left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500);
370
+
371
+ IF jsonb_typeof(p_payload->'tags') = 'array' THEN
372
+ SELECT array_agg(btrim(x)) INTO v_tags
373
+ FROM jsonb_array_elements_text(p_payload->'tags') AS x
374
+ WHERE btrim(x) <> '';
375
+ END IF;
376
+
377
+ -- An author id is a claim about a person in THIS tenant. Unverified, it would
378
+ -- let an external publisher put a real employee's name on anything it writes.
379
+ IF nullif(btrim(p_payload->>'author_member_id'), '') IS NOT NULL THEN
380
+ BEGIN
381
+ SELECT tm.id INTO v_author
382
+ FROM public.tenant_members tm
383
+ WHERE tm.tenant_id = v_tenant AND tm.id = (p_payload->>'author_member_id')::uuid;
384
+ EXCEPTION WHEN OTHERS THEN
385
+ v_author := NULL;
386
+ END;
387
+ END IF;
388
+
389
+ v_cover := public.plg_blog_safe_url(
390
+ coalesce(nullif(btrim(p_payload->>'cover_image'), ''), nullif(btrim(p_payload->>'og_image'), '')));
391
+
392
+ -- \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
393
+ IF v_ref IS NOT NULL THEN
394
+ SELECT p.id, p.published_at, p.slug INTO v_existing_id, v_existing_pub, v_existing_slug
395
+ FROM public.plg_blog_posts p
396
+ WHERE p.tenant_id = v_tenant AND p.external_ref = v_ref;
397
+ END IF;
398
+
399
+ -- On re-delivery the existing slug WINS over one re-derived from the title.
400
+ -- A published post's slug is its URL: silently re-slugging it because the
401
+ -- publisher fixed a typo in the headline breaks every inbound link \u2014 on an
402
+ -- SEO product's own articles, of all things. Changing it stays possible, but
403
+ -- only by saying so explicitly in \`slug\`.
404
+ v_base := coalesce(
405
+ nullif(public.plg_blog_slugify(p_payload->>'slug'), ''),
406
+ nullif(v_existing_slug, ''),
407
+ nullif(public.plg_blog_slugify(v_title), ''),
408
+ 'post-' || left(md5(coalesce(v_ref, gen_random_uuid()::text)), 8));
409
+ v_base := left(v_base, 120);
410
+
411
+ -- Slug is unique per tenant (001). Two different articles that slugify the
412
+ -- same must not make the second delivery fail \u2014 a suffixed slug is a smaller
413
+ -- surprise than a rejected publish.
414
+ v_slug := v_base;
415
+ WHILE EXISTS (
416
+ SELECT 1 FROM public.plg_blog_posts p
417
+ WHERE p.tenant_id = v_tenant AND p.slug = v_slug
418
+ AND (v_existing_id IS NULL OR p.id <> v_existing_id)
419
+ ) LOOP
420
+ v_n := v_n + 1;
421
+ IF v_n > 50 THEN
422
+ RAISE EXCEPTION 'blog_upsert_external_post: cannot derive a free slug from %', v_base
423
+ USING ERRCODE = 'invalid_parameter_value';
424
+ END IF;
425
+ v_slug := left(v_base, 112) || '-' || v_n;
426
+ END LOOP;
427
+
428
+ BEGIN
429
+ v_pub := nullif(btrim(p_payload->>'published_at'), '')::timestamptz;
430
+ EXCEPTION WHEN OTHERS THEN
431
+ v_pub := NULL;
432
+ END;
433
+ -- A post that goes back to draft keeps the date it first went out; the public
434
+ -- view filters on status, so nothing leaks, and the history stays true.
435
+ v_pub := coalesce(v_pub, v_existing_pub, CASE WHEN v_status = 'published' THEN now() END);
436
+
437
+ -- \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
438
+ IF v_existing_id IS NOT NULL THEN
439
+ UPDATE public.plg_blog_posts SET
440
+ slug = v_slug,
441
+ title = v_title,
442
+ excerpt = v_excerpt,
443
+ body = v_body,
444
+ status = v_status,
445
+ published_at = v_pub,
446
+ source = v_source,
447
+ language = v_lang,
448
+ tags = v_tags,
449
+ author_member_id = coalesce(v_author, author_member_id),
450
+ cover_image = coalesce(v_cover, cover_image),
451
+ meta_title = nullif(btrim(p_payload->>'meta_title'), ''),
452
+ meta_description = nullif(btrim(p_payload->>'meta_description'), ''),
453
+ canonical_url = public.plg_blog_safe_url(p_payload->>'canonical_url'),
454
+ og_image = public.plg_blog_safe_url(p_payload->>'og_image'),
455
+ noindex = coalesce((p_payload->>'noindex')::boolean, false),
456
+ updated_at = now()
457
+ WHERE id = v_existing_id;
458
+ v_id := v_existing_id;
459
+ ELSE
460
+ -- ON CONFLICT on the partial index, not just the SELECT above: two
461
+ -- deliveries of the same article racing each other would both find nothing
462
+ -- and both insert. The index is what makes the second one an update.
463
+ INSERT INTO public.plg_blog_posts (
464
+ tenant_id, slug, title, excerpt, body, status, published_at,
465
+ external_ref, source, language, tags, author_member_id, cover_image,
466
+ meta_title, meta_description, canonical_url, og_image, noindex, updated_at)
467
+ VALUES (
468
+ v_tenant, v_slug, v_title, v_excerpt, v_body, v_status, v_pub,
469
+ v_ref, v_source, v_lang, v_tags, v_author, v_cover,
470
+ nullif(btrim(p_payload->>'meta_title'), ''),
471
+ nullif(btrim(p_payload->>'meta_description'), ''),
472
+ public.plg_blog_safe_url(p_payload->>'canonical_url'),
473
+ public.plg_blog_safe_url(p_payload->>'og_image'),
474
+ coalesce((p_payload->>'noindex')::boolean, false),
475
+ now())
476
+ ON CONFLICT (tenant_id, external_ref) WHERE external_ref IS NOT NULL
477
+ DO UPDATE SET
478
+ title = excluded.title,
479
+ excerpt = excluded.excerpt,
480
+ body = excluded.body,
481
+ status = excluded.status,
482
+ published_at = coalesce(excluded.published_at, plg_blog_posts.published_at),
483
+ source = excluded.source,
484
+ language = excluded.language,
485
+ tags = excluded.tags,
486
+ cover_image = coalesce(excluded.cover_image, plg_blog_posts.cover_image),
487
+ meta_title = excluded.meta_title,
488
+ meta_description = excluded.meta_description,
489
+ canonical_url = excluded.canonical_url,
490
+ og_image = excluded.og_image,
491
+ noindex = excluded.noindex,
492
+ updated_at = now()
493
+ RETURNING id, (xmax = 0) INTO v_id, v_created;
494
+ END IF;
495
+
496
+ RETURN jsonb_build_object(
497
+ 'ok', true,
498
+ 'id', v_id,
499
+ 'slug', v_slug,
500
+ 'status', v_status,
501
+ 'source', v_source,
502
+ 'external_ref', v_ref,
503
+ 'created', coalesce(v_created, false));
504
+ END;
505
+ $$;
506
+
507
+ CREATE FUNCTION public.plg_blog_emit_post_event() RETURNS trigger
508
+ LANGUAGE plpgsql SECURITY DEFINER
509
+ SET search_path TO 'public'
510
+ AS $$
511
+ DECLARE
512
+ v_row jsonb;
513
+ v_tenant uuid;
514
+ v_id text;
515
+ v_payload jsonb;
516
+ BEGIN
517
+ -- A save that changed nothing is not a fact; without this any bulk re-save
518
+ -- buries the real updates.
519
+ IF TG_OP = 'UPDATE' AND OLD IS NOT DISTINCT FROM NEW THEN
520
+ RETURN NULL;
521
+ END IF;
522
+
523
+ IF TG_OP = 'DELETE' THEN
524
+ v_row := to_jsonb(OLD);
525
+ ELSE
526
+ v_row := to_jsonb(NEW);
527
+ END IF;
528
+ v_tenant := nullif(v_row->>'tenant_id', '')::uuid;
529
+ v_id := v_row->>'id';
530
+ -- The contract every consumer reads. Not the row: a whole-row payload puts
531
+ -- the article body inside every webhook by default, and whoever needs it
532
+ -- fetches by id.
533
+ v_payload := jsonb_build_object(
534
+ 'post_id', v_id,
535
+ 'slug', v_row->>'slug',
536
+ 'title', v_row->>'title',
537
+ 'status', v_row->>'status',
538
+ 'source', coalesce(v_row->>'source', 'admin'),
539
+ 'external_ref', v_row->>'external_ref',
540
+ 'language', v_row->>'language');
541
+
542
+ BEGIN
543
+ IF TG_OP = 'INSERT' THEN
544
+ PERFORM public.plg_emit_event('blog.post.created', v_payload, 'blog_post', v_id, v_tenant, NULL);
545
+ IF NEW.status = 'published' THEN
546
+ PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
547
+ END IF;
548
+ ELSIF TG_OP = 'UPDATE' THEN
549
+ PERFORM public.plg_emit_event('blog.post.updated', v_payload, 'blog_post', v_id, v_tenant, NULL);
550
+ IF NEW.status = 'published' AND OLD.status IS DISTINCT FROM 'published' THEN
551
+ PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
552
+ END IF;
553
+ ELSE
554
+ PERFORM public.plg_emit_event('blog.post.deleted', v_payload, 'blog_post', v_id, v_tenant, NULL);
555
+ END IF;
556
+ EXCEPTION WHEN OTHERS THEN
557
+ -- Observability must never be able to reject the write that caused it.
558
+ RAISE WARNING '[plg_blog_emit_post_event] % on %: %', TG_OP, TG_TABLE_NAME, SQLERRM;
559
+ END;
560
+
561
+ RETURN NULL;
562
+ END;
563
+ $$;
252
564
 
253
- -- \xA71 \u2500\u2500 columns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
254
- -- Per-post SEO. The blog already had site-level SEO (lib/seo.ts builds the
255
- -- descriptors), but an organic-SEO product's whole output is per-ARTICLE: the
256
- -- title it wants in the SERP is not the title on the page, and a syndicated
257
- -- post has a canonical that points somewhere else entirely.
258
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS meta_title text;
259
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS meta_description text;
260
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS canonical_url text;
261
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS og_image text;
262
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS noindex boolean NOT NULL DEFAULT false;
263
-
264
- -- The external identity. \`external_ref\` is the publisher's id for the article \u2014
265
- -- ours is a uuid it has never seen, so re-delivery can only be recognised by
266
- -- theirs. \`source\` is the anti-loop field: it names who WROTE the post, and a
267
- -- connector reads it to avoid echoing an article back to the system it came
268
- -- from. Defaulting to 'admin' is not a placeholder \u2014 every row that exists
269
- -- today was written by a person in the backoffice, and saying so is what makes
270
- -- the field usable from the first event.
271
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS external_ref text;
272
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'admin';
273
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS language text;
274
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS tags text[];
275
- ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS author_member_id uuid
276
- REFERENCES public.tenant_members(id) ON DELETE SET NULL;
277
-
278
- -- \xA72 \u2500\u2500 identity \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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
- -- This index is the whole of idempotency. Without it the same article delivered
280
- -- twice \u2014 a retry, a redeployed connector, a replayed queue \u2014 lands as two
281
- -- posts with two slugs, and the tenant's blog grows duplicates nobody ordered.
282
- -- Partial because the backoffice's own posts have no external ref and must not
283
- -- collide with each other on NULL.
284
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_external_ref
285
- ON public.plg_blog_posts (tenant_id, external_ref)
286
- WHERE external_ref IS NOT NULL;
287
-
288
- -- "What did this source publish here?" is the connector's own reconciliation
289
- -- query, and the ledger's.
290
- CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_source
291
- ON public.plg_blog_posts (tenant_id, source, updated_at DESC);
292
-
293
- -- \xA73 \u2500\u2500 sanitiser \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
294
- -- The sanitising happens HERE, in the function, and not in the client that
295
- -- renders the post. The RPC below is the only door an external publisher passes
296
- -- through; a browser-side sanitiser protects the one reader whose browser runs
297
- -- it and nothing else \u2014 the row is already poisoned, the RSS feed, the sitemap
298
- -- and every server-rendered page still carry the script. A control has to sit
299
- -- where the data enters, not where it is displayed.
300
-
301
- -- Extract one attribute from a tag, tolerating the three ways HTML quotes a
302
- -- value. \`p_name\` is always a literal this file controls, never caller input.
303
- CREATE OR REPLACE FUNCTION public.plg_blog_html_attr(p_tag text, p_name text)
304
- RETURNS text
305
- LANGUAGE sql
306
- IMMUTABLE
307
- AS $fn$
565
+ CREATE FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) RETURNS text
566
+ LANGUAGE sql IMMUTABLE
567
+ AS $$
308
568
  SELECT coalesce(
309
569
  substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*"([^"]*)"'),
310
570
  substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*''([^'']*)'''),
311
571
  substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*([^\\s">]+)')
312
572
  );
313
- $fn$;
573
+ $$;
314
574
 
315
- CREATE OR REPLACE FUNCTION public.plg_blog_html_escape(p_text text)
316
- RETURNS text
317
- LANGUAGE sql
318
- IMMUTABLE
319
- AS $fn$
575
+ CREATE FUNCTION public.plg_blog_html_escape(p_text text) RETURNS text
576
+ LANGUAGE sql IMMUTABLE
577
+ AS $$
320
578
  SELECT replace(replace(replace(coalesce(p_text, ''), '<', '&lt;'), '>', '&gt;'), '"', '&quot;');
321
- $fn$;
322
-
323
- -- A URL we are willing to put back into the document.
324
- CREATE OR REPLACE FUNCTION public.plg_blog_safe_url(p_url text)
325
- RETURNS text
326
- LANGUAGE plpgsql
327
- IMMUTABLE
328
- AS $fn$
579
+ $$;
580
+
581
+ CREATE FUNCTION public.plg_blog_safe_url(p_url text) RETURNS text
582
+ LANGUAGE plpgsql IMMUTABLE
583
+ AS $$
329
584
  DECLARE
330
585
  v text;
331
586
  BEGIN
@@ -341,14 +596,12 @@ BEGIN
341
596
  END IF;
342
597
  RETURN NULL;
343
598
  END;
344
- $fn$;
345
-
346
- CREATE OR REPLACE FUNCTION public.plg_blog_sanitize_html(p_html text)
347
- RETURNS text
348
- LANGUAGE plpgsql
349
- IMMUTABLE
350
- SET search_path = public
351
- AS $fn$
599
+ $$;
600
+
601
+ CREATE FUNCTION public.plg_blog_sanitize_html(p_html text) RETURNS text
602
+ LANGUAGE plpgsql IMMUTABLE
603
+ SET search_path TO 'public'
604
+ AS $$
352
605
  DECLARE
353
606
  -- The allowlist is what an ARTICLE is made of. Layout tags (div, span,
354
607
  -- section, main) are deliberately out: they carry nothing a blog post needs
@@ -464,15 +717,11 @@ BEGIN
464
717
 
465
718
  RETURN v_out;
466
719
  END;
467
- $fn$;
468
-
469
- -- Accents are transliterated rather than stripped: 'sa\xFAde-bucal' beats
470
- -- 'sa-de-bucal', and a slug is a URL somebody will read out loud.
471
- CREATE OR REPLACE FUNCTION public.plg_blog_slugify(p_text text)
472
- RETURNS text
473
- LANGUAGE sql
474
- IMMUTABLE
475
- AS $fn$
720
+ $$;
721
+
722
+ CREATE FUNCTION public.plg_blog_slugify(p_text text) RETURNS text
723
+ LANGUAGE sql IMMUTABLE
724
+ AS $$
476
725
  SELECT btrim(
477
726
  regexp_replace(
478
727
  translate(lower(coalesce(p_text, '')),
@@ -480,33 +729,334 @@ AS $fn$
480
729
  'aaaaaaeeeeiiiioooooouuuucnyy'),
481
730
  '[^a-z0-9]+', '-', 'g'),
482
731
  '-');
483
- $fn$;
484
-
485
- REVOKE ALL ON FUNCTION public.plg_blog_html_attr(text, text) FROM public, anon;
486
- REVOKE ALL ON FUNCTION public.plg_blog_html_escape(text) FROM public, anon;
487
- REVOKE ALL ON FUNCTION public.plg_blog_safe_url(text) FROM public, anon;
488
- REVOKE ALL ON FUNCTION public.plg_blog_sanitize_html(text) FROM public, anon;
489
- REVOKE ALL ON FUNCTION public.plg_blog_slugify(text) FROM public, anon;
490
- GRANT EXECUTE ON FUNCTION public.plg_blog_html_attr(text, text) TO authenticated, service_role;
491
- GRANT EXECUTE ON FUNCTION public.plg_blog_html_escape(text) TO authenticated, service_role;
492
- GRANT EXECUTE ON FUNCTION public.plg_blog_safe_url(text) TO authenticated, service_role;
493
- GRANT EXECUTE ON FUNCTION public.plg_blog_sanitize_html(text) TO authenticated, service_role;
494
- GRANT EXECUTE ON FUNCTION public.plg_blog_slugify(text) TO authenticated, service_role;
495
-
496
- -- \xA74 \u2500\u2500 ingest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
497
- -- The one door. Takes a single jsonb so the caller never has to know a column
498
- -- list, and returns what it did so a connector can reconcile without a second
499
- -- read.
732
+ $$;
733
+
734
+
735
+ -- \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
736
+
737
+ ALTER TABLE ONLY public.plg_blog_categories
738
+ ADD CONSTRAINT plg_blog_categories_pkey PRIMARY KEY (id);
739
+
740
+ ALTER TABLE ONLY public.plg_blog_posts
741
+ ADD CONSTRAINT plg_blog_posts_pkey PRIMARY KEY (id);
742
+
743
+
744
+ -- \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
745
+
746
+ CREATE VIEW public.v_public_blog_posts AS
747
+ SELECT p.id,
748
+ p.tenant_id,
749
+ p.slug,
750
+ p.title,
751
+ p.excerpt,
752
+ p.body,
753
+ COALESCE(c.name, ''::text) AS tag,
754
+ p.cover_image,
755
+ p.author_name,
756
+ p.author_role,
757
+ p.author_avatar_url,
758
+ p.author_bio,
759
+ p.read_time,
760
+ p.published_at,
761
+ p.meta_title,
762
+ p.meta_description,
763
+ p.canonical_url,
764
+ p.og_image,
765
+ p.noindex,
766
+ p.language,
767
+ p.tags
768
+ FROM (public.plg_blog_posts p
769
+ LEFT JOIN public.plg_blog_categories c ON ((c.id = p.category_id)))
770
+ WHERE (p.status = 'published'::text);
771
+
772
+
773
+ -- \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
774
+
775
+ CREATE INDEX idx_plg_blog_categories_tenant ON public.plg_blog_categories USING btree (tenant_id);
776
+
777
+ CREATE INDEX idx_plg_blog_posts_category ON public.plg_blog_posts USING btree (category_id);
778
+
779
+ CREATE INDEX idx_plg_blog_posts_source ON public.plg_blog_posts USING btree (tenant_id, source, updated_at DESC);
780
+
781
+ CREATE INDEX idx_plg_blog_posts_status ON public.plg_blog_posts USING btree (tenant_id, status, published_at DESC);
782
+
783
+ CREATE INDEX idx_plg_blog_posts_tenant ON public.plg_blog_posts USING btree (tenant_id);
784
+
785
+ CREATE UNIQUE INDEX uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories USING btree (tenant_id, slug);
786
+
787
+ 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);
788
+
789
+ CREATE UNIQUE INDEX uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts USING btree (tenant_id, slug);
790
+
791
+
792
+ -- \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
793
+
794
+ ALTER TABLE ONLY public.plg_blog_categories
795
+ ADD CONSTRAINT plg_blog_categories_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
796
+
797
+ ALTER TABLE ONLY public.plg_blog_posts
798
+ ADD CONSTRAINT plg_blog_posts_author_member_id_fkey FOREIGN KEY (author_member_id) REFERENCES public.tenant_members(id) ON DELETE SET NULL;
799
+
800
+ ALTER TABLE ONLY public.plg_blog_posts
801
+ ADD CONSTRAINT plg_blog_posts_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL;
802
+
803
+ ALTER TABLE ONLY public.plg_blog_posts
804
+ ADD CONSTRAINT plg_blog_posts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
805
+
806
+
807
+ -- \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
808
+
809
+ 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();
810
+
811
+
812
+ -- \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
813
+
814
+ ALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;
815
+
816
+ ALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;
817
+
818
+
819
+ -- \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
820
+
821
+ 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)));
822
+
823
+ 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)));
824
+
825
+ 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)));
826
+
827
+ 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)));
828
+
829
+ 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)));
830
+
831
+ 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)));
832
+
833
+ 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)));
834
+
835
+ 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)));
836
+
837
+
838
+ -- \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
839
+
840
+ REVOKE ALL ON FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;
841
+ GRANT ALL ON FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;
842
+ GRANT ALL ON FUNCTION public.agent_blog_draft_post(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;
843
+
844
+ REVOKE ALL ON FUNCTION public.blog_upsert_external_post(p_payload jsonb) FROM PUBLIC;
845
+ GRANT ALL ON FUNCTION public.blog_upsert_external_post(p_payload jsonb) TO authenticated;
846
+ GRANT ALL ON FUNCTION public.blog_upsert_external_post(p_payload jsonb) TO service_role;
847
+
848
+ REVOKE ALL ON FUNCTION public.plg_blog_emit_post_event() FROM PUBLIC;
849
+ GRANT ALL ON FUNCTION public.plg_blog_emit_post_event() TO authenticated;
850
+ GRANT ALL ON FUNCTION public.plg_blog_emit_post_event() TO service_role;
851
+
852
+ REVOKE ALL ON FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) FROM PUBLIC;
853
+ GRANT ALL ON FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) TO authenticated;
854
+ GRANT ALL ON FUNCTION public.plg_blog_html_attr(p_tag text, p_name text) TO service_role;
855
+
856
+ REVOKE ALL ON FUNCTION public.plg_blog_html_escape(p_text text) FROM PUBLIC;
857
+ GRANT ALL ON FUNCTION public.plg_blog_html_escape(p_text text) TO authenticated;
858
+ GRANT ALL ON FUNCTION public.plg_blog_html_escape(p_text text) TO service_role;
859
+
860
+ REVOKE ALL ON FUNCTION public.plg_blog_safe_url(p_url text) FROM PUBLIC;
861
+ GRANT ALL ON FUNCTION public.plg_blog_safe_url(p_url text) TO authenticated;
862
+ GRANT ALL ON FUNCTION public.plg_blog_safe_url(p_url text) TO service_role;
863
+
864
+ REVOKE ALL ON FUNCTION public.plg_blog_sanitize_html(p_html text) FROM PUBLIC;
865
+ GRANT ALL ON FUNCTION public.plg_blog_sanitize_html(p_html text) TO authenticated;
866
+ GRANT ALL ON FUNCTION public.plg_blog_sanitize_html(p_html text) TO service_role;
867
+
868
+ REVOKE ALL ON FUNCTION public.plg_blog_slugify(p_text text) FROM PUBLIC;
869
+ GRANT ALL ON FUNCTION public.plg_blog_slugify(p_text text) TO authenticated;
870
+ GRANT ALL ON FUNCTION public.plg_blog_slugify(p_text text) TO service_role;
871
+
872
+ GRANT MAINTAIN ON TABLE public.plg_blog_categories TO anon;
873
+ GRANT ALL ON TABLE public.plg_blog_categories TO authenticated;
874
+ GRANT ALL ON TABLE public.plg_blog_categories TO service_role;
875
+
876
+ GRANT MAINTAIN ON TABLE public.plg_blog_posts TO anon;
877
+ GRANT ALL ON TABLE public.plg_blog_posts TO authenticated;
878
+ GRANT ALL ON TABLE public.plg_blog_posts TO service_role;
879
+
880
+ GRANT SELECT,MAINTAIN ON TABLE public.v_public_blog_posts TO anon;
881
+ GRANT ALL ON TABLE public.v_public_blog_posts TO authenticated;
882
+ GRANT ALL ON TABLE public.v_public_blog_posts TO service_role;
883
+
884
+
885
+ -- \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
886
+ -- Put back, so the NEXT thing installed inherits what the chain had.
887
+
888
+ DO $dp$ BEGIN
889
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES 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 postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
892
+ END $dp$;
893
+
894
+ DO $dp$ BEGIN
895
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES 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 postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
898
+ END $dp$;
899
+
900
+ DO $dp$ BEGIN
901
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES 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 postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
904
+ END $dp$;
905
+
906
+ DO $dp$ BEGIN
907
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES 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 postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
910
+ END $dp$;
911
+
912
+ DO $dp$ BEGIN
913
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;
914
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
915
+ 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$;
916
+ END $dp$;
917
+
918
+ DO $dp$ BEGIN
919
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;
920
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
921
+ 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$;
922
+ END $dp$;
923
+
924
+ DO $dp$ BEGIN
925
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;
926
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
927
+ 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$;
928
+ END $dp$;
929
+
930
+ DO $dp$ BEGIN
931
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;
932
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
933
+ 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$;
934
+ END $dp$;
935
+
936
+ DO $dp$ BEGIN
937
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
938
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
939
+ 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$;
940
+ END $dp$;
941
+
942
+ DO $dp$ BEGIN
943
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
944
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
945
+ 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$;
946
+ END $dp$;
947
+
948
+ DO $dp$ BEGIN
949
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
950
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
951
+ 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$;
952
+ END $dp$;
953
+
954
+ DO $dp$ BEGIN
955
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;
956
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
957
+ 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$;
958
+ END $dp$;
959
+
960
+ DO $dp$ BEGIN
961
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;
962
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
963
+ 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$;
964
+ END $dp$;
965
+
966
+ DO $dp$ BEGIN
967
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;
968
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
969
+ 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$;
970
+ END $dp$;
971
+
972
+ DO $dp$ BEGIN
973
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;
974
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
975
+ 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$;
976
+ END $dp$;
977
+
978
+ DO $dp$ BEGIN
979
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
980
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
981
+ 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$;
982
+ END $dp$;
983
+
984
+ DO $dp$ BEGIN
985
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;
986
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
987
+ 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$;
988
+ END $dp$;
989
+
990
+ DO $dp$ BEGIN
991
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
992
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
993
+ 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$;
994
+ END $dp$;
995
+
996
+ DO $dp$ BEGIN
997
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
998
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
999
+ 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$;
1000
+ END $dp$;
1001
+
1002
+ DO $dp$ BEGIN
1003
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;
1004
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1005
+ 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$;
1006
+ END $dp$;
1007
+
1008
+ DO $dp$ BEGIN
1009
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;
1010
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1011
+ 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$;
1012
+ END $dp$;
1013
+
1014
+ DO $dp$ BEGIN
1015
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;
1016
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1017
+ 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$;
1018
+ END $dp$;
1019
+
1020
+ DO $dp$ BEGIN
1021
+ EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;
1022
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1023
+ 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$;
1024
+ END $dp$;
1025
+
1026
+ DO $dp$ BEGIN
1027
+ EXECUTE $stmt$--
1028
+ -- PostgreSQL database dump complete
1029
+ --$stmt$;
1030
+ EXCEPTION WHEN insufficient_privilege OR undefined_object THEN
1031
+ RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--
1032
+ -- PostgreSQL database dump complete
1033
+ --$stmt$;
1034
+ END $dp$;
1035
+ `;
1036
+ var MIGRATION_001_THE_AUTHOR_IS_A_MEMBERSHIP = `-- ============================================================================
1037
+ -- 001_the_author_is_a_membership \u2014 the FK and the lookup leave the projection
1038
+ -- (#214 / #352).
1039
+ --
1040
+ -- plg_blog_posts.author_member_id pointed at public.tenant_members(id), which
1041
+ -- is a COMPAT PROJECTION of app.memberships maintained by triggers \u2014 so a real
1042
+ -- foreign key was resting on a derived table. That is the reason the projection
1043
+ -- could not be dropped, and it is a stronger reason than "a screen reads it":
1044
+ -- Postgres refuses to drop a table another table references.
500
1045
  --
501
- -- Payload: external_ref, title, slug, excerpt, body_html, status, source,
502
- -- language, tags[], author_member_id, published_at, cover_image, meta_title,
503
- -- meta_description, canonical_url, og_image, noindex.
504
- CREATE OR REPLACE FUNCTION public.blog_upsert_external_post(p_payload jsonb)
505
- RETURNS jsonb
506
- LANGUAGE plpgsql
507
- SECURITY DEFINER
508
- SET search_path = public
509
- AS $fn$
1046
+ -- The move is safe precisely because the projection's own comment says the ids
1047
+ -- are the same ("COMPAT PROJECTION of app.memberships (#134): same ids"), so
1048
+ -- every existing author_member_id already names an app.memberships row.
1049
+ -- ============================================================================
1050
+
1051
+ ALTER TABLE public.plg_blog_posts DROP CONSTRAINT IF EXISTS plg_blog_posts_author_member_id_fkey;
1052
+ ALTER TABLE public.plg_blog_posts
1053
+ ADD CONSTRAINT plg_blog_posts_author_member_id_fkey
1054
+ FOREIGN KEY (author_member_id) REFERENCES app.memberships(id) ON DELETE SET NULL;
1055
+
1056
+ CREATE OR REPLACE FUNCTION public.blog_upsert_external_post(p_payload jsonb) RETURNS jsonb
1057
+ LANGUAGE plpgsql SECURITY DEFINER
1058
+ SET search_path TO 'public'
1059
+ AS $$
510
1060
  DECLARE
511
1061
  v_tenants uuid[];
512
1062
  v_tenant uuid;
@@ -590,7 +1140,7 @@ BEGIN
590
1140
  IF nullif(btrim(p_payload->>'author_member_id'), '') IS NOT NULL THEN
591
1141
  BEGIN
592
1142
  SELECT tm.id INTO v_author
593
- FROM public.tenant_members tm
1143
+ FROM app.memberships tm
594
1144
  WHERE tm.tenant_id = v_tenant AND tm.id = (p_payload->>'author_member_id')::uuid;
595
1145
  EXCEPTION WHEN OTHERS THEN
596
1146
  v_author := NULL;
@@ -713,210 +1263,10 @@ BEGIN
713
1263
  'external_ref', v_ref,
714
1264
  'created', coalesce(v_created, false));
715
1265
  END;
716
- $fn$;
717
-
718
- REVOKE ALL ON FUNCTION public.blog_upsert_external_post(jsonb) FROM public, anon;
719
- GRANT EXECUTE ON FUNCTION public.blog_upsert_external_post(jsonb) TO authenticated, service_role;
720
-
721
- -- \xA75 \u2500\u2500 agent \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
722
- -- The assistant's write, in the shape the broker calls (p_tenant_id,
723
- -- p_actor_user_id, p_payload) \u2014 a different signature from \xA74 on purpose: the
724
- -- broker injects a verified actor and agent_guard runs role \u2192 plan \u2192 the
725
- -- blog_posts cap before anything is written. A draft, never a publish: an agent
726
- -- that can put a page on the public site without anyone reading it first is a
727
- -- worse product than one that cannot.
728
- CREATE OR REPLACE FUNCTION public.agent_blog_draft_post(
729
- p_tenant_id uuid,
730
- p_actor_user_id uuid,
731
- p_payload jsonb
732
- ) RETURNS jsonb
733
- LANGUAGE plpgsql
734
- SECURITY DEFINER
735
- SET search_path = public
736
- AS $fn$
737
- DECLARE
738
- v_denial jsonb;
739
- v_used int;
740
- v_title text;
741
- v_base text;
742
- v_slug text;
743
- v_n int := 1;
744
- v_id uuid;
745
- BEGIN
746
- v_title := nullif(btrim(p_payload->>'title'), '');
747
- IF v_title IS NULL THEN
748
- RETURN jsonb_build_object('ok', false, 'error', 'title is required to draft a post');
749
- END IF;
750
-
751
- SELECT count(*) INTO v_used FROM public.plg_blog_posts p WHERE p.tenant_id = p_tenant_id;
752
- v_denial := public.agent_guard(p_tenant_id, p_actor_user_id, 'blog', 'create', 'blog_posts', v_used, 1);
753
- IF v_denial IS NOT NULL THEN
754
- RETURN jsonb_build_object('ok', false, 'denial', v_denial);
755
- END IF;
756
-
757
- v_base := coalesce(nullif(public.plg_blog_slugify(v_title), ''), 'post-' || left(md5(v_title), 8));
758
- v_slug := left(v_base, 120);
759
- WHILE EXISTS (SELECT 1 FROM public.plg_blog_posts p
760
- WHERE p.tenant_id = p_tenant_id AND p.slug = v_slug) LOOP
761
- v_n := v_n + 1;
762
- IF v_n > 50 THEN
763
- RETURN jsonb_build_object('ok', false, 'error', format('cannot derive a free slug from %L', v_base));
764
- END IF;
765
- v_slug := left(v_base, 112) || '-' || v_n;
766
- END LOOP;
767
-
768
- INSERT INTO public.plg_blog_posts (
769
- tenant_id, slug, title, excerpt, body, status, source, language, tags, updated_at)
770
- VALUES (
771
- p_tenant_id, v_slug, v_title,
772
- left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500),
773
- -- Same sanitiser as the external door: the model's output is not more
774
- -- trustworthy than a partner's, it is just closer to home.
775
- public.plg_blog_sanitize_html(p_payload->>'body_html'),
776
- 'draft', 'agent',
777
- nullif(btrim(p_payload->>'language'), ''),
778
- CASE WHEN jsonb_typeof(p_payload->'tags') = 'array'
779
- THEN ARRAY(SELECT btrim(x) FROM jsonb_array_elements_text(p_payload->'tags') AS x WHERE btrim(x) <> '')
780
- END,
781
- now())
782
- RETURNING id INTO v_id;
783
-
784
- INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
785
- VALUES (p_tenant_id, p_actor_user_id, 'agent.draftBlogPost', 'blog_post', v_id::text,
786
- jsonb_build_object('title', v_title, 'slug', v_slug));
787
-
788
- RETURN jsonb_build_object(
789
- 'ok', true,
790
- 'id', v_id,
791
- 'record', jsonb_build_object(
792
- 'ref', jsonb_build_object('id', v_id, 'resource', 'plg_blog_posts', 'entityKey', 'blog:posts'),
793
- 'name', v_title,
794
- 'slug', v_slug,
795
- 'status', 'draft'));
796
- END;
797
- $fn$;
798
-
799
- REVOKE ALL ON FUNCTION public.agent_blog_draft_post(uuid, uuid, jsonb) FROM public, anon;
800
- GRANT EXECUTE ON FUNCTION public.agent_blog_draft_post(uuid, uuid, jsonb) TO authenticated, service_role;
801
-
802
- -- \xA76 \u2500\u2500 facts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
803
- -- One trigger on the table rather than an emit call inside each writer. The
804
- -- backoffice, this file's two RPCs and a hand-run SQL fix all end up in the
805
- -- same place, so a connector hears the same four facts no matter who wrote \u2014
806
- -- which is the entire reason the row-level emitter exists (packages/db/
807
- -- migrations/027_domain_events.sql).
808
- --
809
- -- \`published\` is not a row action, which is why the generic
810
- -- plg_attach_event_source trigger cannot produce it: it is a TRANSITION, and
811
- -- only something holding OLD and NEW can see it happen.
812
- CREATE OR REPLACE FUNCTION public.plg_blog_emit_post_event()
813
- RETURNS trigger
814
- LANGUAGE plpgsql
815
- SECURITY DEFINER
816
- SET search_path = public
817
- AS $fn$
818
- DECLARE
819
- v_row jsonb;
820
- v_tenant uuid;
821
- v_id text;
822
- v_payload jsonb;
823
- BEGIN
824
- -- A save that changed nothing is not a fact; without this any bulk re-save
825
- -- buries the real updates.
826
- IF TG_OP = 'UPDATE' AND OLD IS NOT DISTINCT FROM NEW THEN
827
- RETURN NULL;
828
- END IF;
829
-
830
- IF TG_OP = 'DELETE' THEN
831
- v_row := to_jsonb(OLD);
832
- ELSE
833
- v_row := to_jsonb(NEW);
834
- END IF;
835
- v_tenant := nullif(v_row->>'tenant_id', '')::uuid;
836
- v_id := v_row->>'id';
837
- -- The contract every consumer reads. Not the row: a whole-row payload puts
838
- -- the article body inside every webhook by default, and whoever needs it
839
- -- fetches by id.
840
- v_payload := jsonb_build_object(
841
- 'post_id', v_id,
842
- 'slug', v_row->>'slug',
843
- 'title', v_row->>'title',
844
- 'status', v_row->>'status',
845
- 'source', coalesce(v_row->>'source', 'admin'),
846
- 'external_ref', v_row->>'external_ref',
847
- 'language', v_row->>'language');
848
-
849
- BEGIN
850
- IF TG_OP = 'INSERT' THEN
851
- PERFORM public.plg_emit_event('blog.post.created', v_payload, 'blog_post', v_id, v_tenant, NULL);
852
- IF NEW.status = 'published' THEN
853
- PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
854
- END IF;
855
- ELSIF TG_OP = 'UPDATE' THEN
856
- PERFORM public.plg_emit_event('blog.post.updated', v_payload, 'blog_post', v_id, v_tenant, NULL);
857
- IF NEW.status = 'published' AND OLD.status IS DISTINCT FROM 'published' THEN
858
- PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
859
- END IF;
860
- ELSE
861
- PERFORM public.plg_emit_event('blog.post.deleted', v_payload, 'blog_post', v_id, v_tenant, NULL);
862
- END IF;
863
- EXCEPTION WHEN OTHERS THEN
864
- -- Observability must never be able to reject the write that caused it.
865
- RAISE WARNING '[plg_blog_emit_post_event] % on %: %', TG_OP, TG_TABLE_NAME, SQLERRM;
866
- END;
867
-
868
- RETURN NULL;
869
- END;
870
- $fn$;
871
-
872
- REVOKE ALL ON FUNCTION public.plg_blog_emit_post_event() FROM public, anon;
873
-
874
- DROP TRIGGER IF EXISTS plg_blog_posts_emit_facts ON public.plg_blog_posts;
875
- CREATE TRIGGER plg_blog_posts_emit_facts
876
- AFTER INSERT OR UPDATE OR DELETE ON public.plg_blog_posts
877
- FOR EACH ROW EXECUTE FUNCTION public.plg_blog_emit_post_event();
878
-
879
- -- \xA77 \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
880
- -- The public site runs anon and reads only this view, so per-post SEO that the
881
- -- view does not carry cannot be rendered \u2014 the columns would exist and no page
882
- -- would ever show them.
883
- --
884
- -- \`source\` and \`external_ref\` stay out: they are operational facts about who
885
- -- writes this tenant's blog, and the anon surface is the open internet.
886
- -- New columns are appended, never reordered \u2014 CREATE OR REPLACE VIEW only
887
- -- tolerates additions at the end.
888
- CREATE OR REPLACE VIEW public.v_public_blog_posts AS
889
- SELECT
890
- p.id,
891
- p.tenant_id,
892
- p.slug,
893
- p.title,
894
- p.excerpt,
895
- p.body,
896
- COALESCE(c.name, '') AS tag,
897
- p.cover_image,
898
- p.author_name,
899
- p.author_role,
900
- p.author_avatar_url,
901
- p.author_bio,
902
- p.read_time,
903
- p.published_at,
904
- p.meta_title,
905
- p.meta_description,
906
- p.canonical_url,
907
- p.og_image,
908
- p.noindex,
909
- p.language,
910
- p.tags
911
- FROM public.plg_blog_posts p
912
- LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
913
- WHERE p.status = 'published';
914
-
915
- GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
916
- `;
1266
+ $$;`;
917
1267
  var MIGRATIONS = [
918
- { id: "001_blog", sql: MIGRATION_001_BLOG },
919
- { id: "002_external_posts", sql: MIGRATION_002_EXTERNAL_POSTS }
1268
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
1269
+ { id: "001_the_author_is_a_membership", sql: MIGRATION_001_THE_AUTHOR_IS_A_MEMBERSHIP }
920
1270
  ];
921
1271
 
922
1272
  // src/data/runtime.ts