@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 +798 -448
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +2 -2
- package/dist/migrations/index.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -113,219 +113,474 @@ function createSupabaseBlogProvider(options) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
// src/migrations/index.ts
|
|
116
|
-
var
|
|
117
|
-
-- plugin-blog
|
|
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
|
-
--
|
|
120
|
-
-- the
|
|
121
|
-
--
|
|
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
|
-
--
|
|
127
|
-
--
|
|
128
|
-
--
|
|
129
|
-
--
|
|
130
|
-
--
|
|
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
|
-
--
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
573
|
+
$$;
|
|
314
574
|
|
|
315
|
-
CREATE
|
|
316
|
-
|
|
317
|
-
|
|
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, ''), '<', '<'), '>', '>'), '"', '"');
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
CREATE
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
--
|
|
502
|
-
--
|
|
503
|
-
--
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
-
|
|
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: "
|
|
919
|
-
{ id: "
|
|
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
|