@fayz-ai/plugin-blog 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useState, useEffect, createElement } from 'react';
2
- import { useSeo, createSafeDataProvider, getActiveTenantId, getSupabaseClientOptional, organizationJsonLd, absoluteUrl, breadcrumbJsonLd } from '@fayz-ai/core';
2
+ import { absoluteUrl, organizationJsonLd, breadcrumbJsonLd, useSeo, hasLoader, defineLoader, createSafeDataProvider, getActiveTenantId, getSupabaseClientOptional } from '@fayz-ai/core';
3
3
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
4
  import { Link, useParams } from 'react-router-dom';
5
5
  import { Clock } from 'lucide-react';
@@ -200,11 +200,17 @@ function PostCard({ post, basePath = "/blog" }) {
200
200
  }
201
201
  );
202
202
  }
203
- function postUrl(basePath, slug) {
204
- return absoluteUrl(`${basePath}/${slug}`);
203
+ function blogUrl(basePath, path, origin) {
204
+ const joined = `${basePath}${path}`.replace(/\/+/g, "/");
205
+ if (origin) return `${origin.replace(/\/$/, "")}${joined.startsWith("/") ? joined : `/${joined}`}`;
206
+ const ambient = absoluteUrl(joined);
207
+ return ambient.startsWith("http") ? ambient : void 0;
208
+ }
209
+ function postUrl(basePath, slug, origin) {
210
+ return blogUrl(basePath, `/${slug}`, origin);
205
211
  }
206
212
  function articleJsonLd(post, opts) {
207
- const url = postUrl(opts.basePath, post.slug);
213
+ const url = postUrl(opts.basePath, post.slug, opts.origin);
208
214
  const posting = {
209
215
  "@context": "https://schema.org",
210
216
  "@type": "BlogPosting",
@@ -212,7 +218,7 @@ function articleJsonLd(post, opts) {
212
218
  description: post.excerpt,
213
219
  image: post.image ? [post.image] : void 0,
214
220
  articleSection: post.tag,
215
- mainEntityOfPage: { "@type": "WebPage", "@id": url },
221
+ ...url ? { mainEntityOfPage: { "@type": "WebPage", "@id": url } } : {},
216
222
  author: {
217
223
  "@type": "Person",
218
224
  name: post.author.name,
@@ -221,11 +227,9 @@ function articleJsonLd(post, opts) {
221
227
  ...post.publishedAt ? { datePublished: post.publishedAt, dateModified: post.publishedAt } : {},
222
228
  ...opts.siteName ? { publisher: organizationJsonLd(opts.siteName) } : {}
223
229
  };
224
- const breadcrumb = breadcrumbJsonLd([
225
- { name: "Blog", url: absoluteUrl(opts.basePath) },
226
- { name: post.title, url }
227
- ]);
228
- return [posting, breadcrumb];
230
+ const listUrl = blogUrl(opts.basePath, "", opts.origin);
231
+ if (!url || !listUrl) return [posting];
232
+ return [posting, breadcrumbJsonLd([{ name: "Blog", url: listUrl }, { name: post.title, url }])];
229
233
  }
230
234
  function blogJsonLd(posts, opts) {
231
235
  return [
@@ -233,12 +237,12 @@ function blogJsonLd(posts, opts) {
233
237
  "@context": "https://schema.org",
234
238
  "@type": "Blog",
235
239
  name: opts.title,
236
- url: absoluteUrl(opts.basePath),
240
+ ...blogUrl(opts.basePath, "", opts.origin) ? { url: blogUrl(opts.basePath, "", opts.origin) } : {},
237
241
  ...opts.siteName ? { publisher: organizationJsonLd(opts.siteName) } : {},
238
242
  blogPost: posts.map((p) => ({
239
243
  "@type": "BlogPosting",
240
244
  headline: p.title,
241
- url: postUrl(opts.basePath, p.slug),
245
+ ...postUrl(opts.basePath, p.slug, opts.origin) ? { url: postUrl(opts.basePath, p.slug, opts.origin) } : {},
242
246
  image: p.image,
243
247
  author: { "@type": "Person", name: p.author.name },
244
248
  ...p.publishedAt ? { datePublished: p.publishedAt } : {}
@@ -246,6 +250,28 @@ function blogJsonLd(posts, opts) {
246
250
  }
247
251
  ];
248
252
  }
253
+ function postSeo(post, opts) {
254
+ return {
255
+ title: opts.siteName ? `${post.title} \u2014 ${opts.siteName}` : post.title,
256
+ description: post.excerpt?.slice(0, 160),
257
+ canonical: postUrl(opts.basePath, post.slug, opts.origin),
258
+ image: post.image,
259
+ // 'article', not 'website': it is what tells a share card to show the author
260
+ // and published date rather than treating the post as a site landing page.
261
+ type: "article",
262
+ jsonLd: articleJsonLd(post, opts)
263
+ };
264
+ }
265
+ function blogListSeo(posts, opts) {
266
+ return {
267
+ title: opts.siteName ? `${opts.title} \u2014 ${opts.siteName}` : opts.title,
268
+ description: opts.description,
269
+ canonical: blogUrl(opts.basePath, "", opts.origin),
270
+ image: posts.find((p) => p.image)?.image,
271
+ type: "website",
272
+ jsonLd: blogJsonLd(posts, opts)
273
+ };
274
+ }
249
275
  function BlogList() {
250
276
  const { config } = useBlogContext();
251
277
  const { posts, loading, error } = useBlogPosts();
@@ -468,19 +494,863 @@ FROM public.plg_blog_posts p
468
494
  LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
469
495
  WHERE p.status = 'published';
470
496
 
497
+ GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
498
+ `;
499
+ var MIGRATION_002_EXTERNAL_POSTS = `-- ============================================================================
500
+ -- plugin-blog 002: the ingest seam \u2014 per-post SEO, external identity, and the
501
+ -- single function an outside publisher passes through.
502
+ --
503
+ -- 001 gave the blog a content model written by one hand: the backoffice, behind
504
+ -- a session, through RLS. RankLayer publishes INTO the tenant instead, so the
505
+ -- write arrives from a machine we do not run, carrying HTML we did not author
506
+ -- and an identity that lives in someone else's database. Three things follow,
507
+ -- and each is a section here.
508
+ --
509
+ -- \xA71 columns per-post SEO + the external identity (external_ref, source\u2026)
510
+ -- \xA72 identity partial unique index on (tenant_id, external_ref)
511
+ -- \xA73 sanitiser plg_blog_sanitize_html \u2014 a tag allowlist, in SQL
512
+ -- \xA74 ingest blog_upsert_external_post(p_payload jsonb)
513
+ -- \xA75 agent agent_blog_draft_post \u2014 the assistant's write, guarded
514
+ -- \xA76 facts blog.post.{created,updated,published,deleted}
515
+ -- \xA77 view v_public_blog_posts extended with the SEO the site renders
516
+ --
517
+ -- Additive and idempotent: no table is created, no policy is rewritten, the
518
+ -- existing tenant RLS from 001 already covers every column added here.
519
+ -- ============================================================================
520
+
521
+ -- \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
522
+ -- Per-post SEO. The blog already had site-level SEO (lib/seo.ts builds the
523
+ -- descriptors), but an organic-SEO product's whole output is per-ARTICLE: the
524
+ -- title it wants in the SERP is not the title on the page, and a syndicated
525
+ -- post has a canonical that points somewhere else entirely.
526
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS meta_title text;
527
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS meta_description text;
528
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS canonical_url text;
529
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS og_image text;
530
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS noindex boolean NOT NULL DEFAULT false;
531
+
532
+ -- The external identity. \`external_ref\` is the publisher's id for the article \u2014
533
+ -- ours is a uuid it has never seen, so re-delivery can only be recognised by
534
+ -- theirs. \`source\` is the anti-loop field: it names who WROTE the post, and a
535
+ -- connector reads it to avoid echoing an article back to the system it came
536
+ -- from. Defaulting to 'admin' is not a placeholder \u2014 every row that exists
537
+ -- today was written by a person in the backoffice, and saying so is what makes
538
+ -- the field usable from the first event.
539
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS external_ref text;
540
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'admin';
541
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS language text;
542
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS tags text[];
543
+ ALTER TABLE public.plg_blog_posts ADD COLUMN IF NOT EXISTS author_member_id uuid
544
+ REFERENCES public.tenant_members(id) ON DELETE SET NULL;
545
+
546
+ -- \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
547
+ -- This index is the whole of idempotency. Without it the same article delivered
548
+ -- twice \u2014 a retry, a redeployed connector, a replayed queue \u2014 lands as two
549
+ -- posts with two slugs, and the tenant's blog grows duplicates nobody ordered.
550
+ -- Partial because the backoffice's own posts have no external ref and must not
551
+ -- collide with each other on NULL.
552
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_external_ref
553
+ ON public.plg_blog_posts (tenant_id, external_ref)
554
+ WHERE external_ref IS NOT NULL;
555
+
556
+ -- "What did this source publish here?" is the connector's own reconciliation
557
+ -- query, and the ledger's.
558
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_source
559
+ ON public.plg_blog_posts (tenant_id, source, updated_at DESC);
560
+
561
+ -- \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
562
+ -- The sanitising happens HERE, in the function, and not in the client that
563
+ -- renders the post. The RPC below is the only door an external publisher passes
564
+ -- through; a browser-side sanitiser protects the one reader whose browser runs
565
+ -- it and nothing else \u2014 the row is already poisoned, the RSS feed, the sitemap
566
+ -- and every server-rendered page still carry the script. A control has to sit
567
+ -- where the data enters, not where it is displayed.
568
+
569
+ -- Extract one attribute from a tag, tolerating the three ways HTML quotes a
570
+ -- value. \`p_name\` is always a literal this file controls, never caller input.
571
+ CREATE OR REPLACE FUNCTION public.plg_blog_html_attr(p_tag text, p_name text)
572
+ RETURNS text
573
+ LANGUAGE sql
574
+ IMMUTABLE
575
+ AS $fn$
576
+ SELECT coalesce(
577
+ substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*"([^"]*)"'),
578
+ substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*''([^'']*)'''),
579
+ substring(p_tag from '(?i)\\s' || p_name || '\\s*=\\s*([^\\s">]+)')
580
+ );
581
+ $fn$;
582
+
583
+ CREATE OR REPLACE FUNCTION public.plg_blog_html_escape(p_text text)
584
+ RETURNS text
585
+ LANGUAGE sql
586
+ IMMUTABLE
587
+ AS $fn$
588
+ SELECT replace(replace(replace(coalesce(p_text, ''), '<', '&lt;'), '>', '&gt;'), '"', '&quot;');
589
+ $fn$;
590
+
591
+ -- A URL we are willing to put back into the document.
592
+ CREATE OR REPLACE FUNCTION public.plg_blog_safe_url(p_url text)
593
+ RETURNS text
594
+ LANGUAGE plpgsql
595
+ IMMUTABLE
596
+ AS $fn$
597
+ DECLARE
598
+ v text;
599
+ BEGIN
600
+ IF p_url IS NULL THEN RETURN NULL; END IF;
601
+ -- Whitespace and control characters go BEFORE the scheme test: \`java\\tscript:\`
602
+ -- is a working URL in every browser and would pass a naive prefix check.
603
+ v := regexp_replace(btrim(p_url), '[[:space:][:cntrl:]]', '', 'g');
604
+ IF v = '' THEN RETURN NULL; END IF;
605
+ -- Allowlist of schemes. \`data:\` is absent on purpose: a data URL is a way to
606
+ -- ship a whole document \u2014 including an SVG that scripts \u2014 inside an href.
607
+ IF v ~* '^(https?://|mailto:|/|#)' THEN
608
+ RETURN public.plg_blog_html_escape(v);
609
+ END IF;
610
+ RETURN NULL;
611
+ END;
612
+ $fn$;
613
+
614
+ CREATE OR REPLACE FUNCTION public.plg_blog_sanitize_html(p_html text)
615
+ RETURNS text
616
+ LANGUAGE plpgsql
617
+ IMMUTABLE
618
+ SET search_path = public
619
+ AS $fn$
620
+ DECLARE
621
+ -- The allowlist is what an ARTICLE is made of. Layout tags (div, span,
622
+ -- section, main) are deliberately out: they carry nothing a blog post needs
623
+ -- and every one of them is a place to hang an attribute. Anything not listed
624
+ -- loses its tag and keeps its text, so a stripped wrapper degrades to prose
625
+ -- rather than to a hole in the article.
626
+ c_allowed constant text[] := ARRAY[
627
+ 'p','br','hr','h1','h2','h3','h4','h5','h6',
628
+ 'strong','b','em','i','u','s','sup','sub','code','pre','blockquote','q','cite',
629
+ 'ul','ol','li','dl','dt','dd',
630
+ 'a','img','figure','figcaption',
631
+ 'table','thead','tbody','tfoot','tr','th','td','caption'
632
+ ];
633
+ c_void constant text[] := ARRAY['br','hr','img'];
634
+ -- Elements whose CONTENT is code rather than text.
635
+ c_active constant text :=
636
+ 'script|style|iframe|object|embed|applet|form|noscript|svg|math|template|frameset|frame';
637
+ v text;
638
+ v_rest text;
639
+ v_out text := '';
640
+ v_lt int;
641
+ v_gt int;
642
+ v_tag text;
643
+ v_name text;
644
+ v_val text;
645
+ v_attrs text;
646
+ BEGIN
647
+ IF p_html IS NULL OR btrim(p_html) = '' THEN RETURN ''; END IF;
648
+ v := p_html;
649
+
650
+ -- Comments first: a conditional comment is executable markup in older
651
+ -- engines, and \`<!-- <script>\u2026 -->\` would otherwise reach the pass below
652
+ -- already commented out and survive it.
653
+ v := regexp_replace(v, '<!--.*?-->', '', 'g');
654
+ v := regexp_replace(v, '<!\\[CDATA\\[.*?\\]\\]>', '', 'g');
655
+ v := regexp_replace(v, '<\\?.*?\\?>', '', 'g');
656
+
657
+ -- Active elements go whole. Dropping only the tag would leave the script body
658
+ -- standing as visible page text \u2014 the article would read as vandalised even
659
+ -- though nothing executed.
660
+ -- Two Postgres regex traps, both of which fail OPEN and were caught by
661
+ -- running this against a container rather than by reading it:
662
+ -- \`\\y\`, not \`\\b\` \u2014 in a Postgres RE \`\\b\` is the BACKSPACE character, so
663
+ -- \`<script\\b\` matches nothing and the script body survives as page text.
664
+ -- Every quantifier non-greedy \u2014 a Postgres RE takes its greediness from its
665
+ -- FIRST quantifier, so one greedy \`\\s*\` at the front makes the trailing
666
+ -- \`.*?\` greedy too, and the first <script> swallows the article down to the
667
+ -- last closing tag in the document.
668
+ v := regexp_replace(v, '<\\s*?(' || c_active || ')\\y[^>]*?>.*?<\\s*?/\\s*?\\1\\s*?>', '', 'gi');
669
+ -- \u2026and the unbalanced halves of the same set, plus the document-level tags
670
+ -- that have no business inside an article body.
671
+ v := regexp_replace(
672
+ v,
673
+ '<\\s*/?\\s*(' || c_active || '|link|meta|base|title|head|body|html)\\y[^>]*>',
674
+ '', 'gi');
675
+
676
+ -- Walk the remaining tags. A rewrite rather than a filter: every tag that
677
+ -- comes out the other side was BUILT here from a name and an allowlisted
678
+ -- attribute, so nothing an attacker wrote is copied through by accident.
679
+ v_rest := v;
680
+ LOOP
681
+ v_lt := position('<' in v_rest);
682
+ IF v_lt = 0 THEN
683
+ v_out := v_out || v_rest;
684
+ EXIT;
685
+ END IF;
686
+ v_out := v_out || substr(v_rest, 1, v_lt - 1);
687
+ v_rest := substr(v_rest, v_lt);
688
+
689
+ v_gt := position('>' in v_rest);
690
+ IF v_gt = 0 THEN
691
+ -- An unterminated '<' is text. It is escaped so it cannot open a tag when
692
+ -- something is concatenated after this body.
693
+ v_out := v_out || replace(v_rest, '<', '&lt;');
694
+ EXIT;
695
+ END IF;
696
+ v_tag := substr(v_rest, 1, v_gt);
697
+ v_rest := substr(v_rest, v_gt + 1);
698
+
699
+ v_name := lower(coalesce(substring(v_tag from '^<\\s*/?\\s*([a-zA-Z][a-zA-Z0-9]*)'), ''));
700
+ IF v_name = '' OR NOT (v_name = ANY (c_allowed)) THEN
701
+ CONTINUE; -- tag dropped, its text kept
702
+ END IF;
703
+
704
+ IF v_tag ~ '^<\\s*/' THEN
705
+ IF NOT (v_name = ANY (c_void)) THEN v_out := v_out || '</' || v_name || '>'; END IF;
706
+ CONTINUE;
707
+ END IF;
708
+
709
+ -- Attributes are an allowlist too, and a per-tag one: \`href\` on <a>,
710
+ -- \`src\`/\`alt\` on <img>. Everything else \u2014 style, on*, srcset, class,
711
+ -- data-* \u2014 is dropped rather than inspected, because inspecting means
712
+ -- maintaining a list of what is dangerous, and that list is never finished.
713
+ v_attrs := '';
714
+ IF v_name = 'a' THEN
715
+ v_val := public.plg_blog_safe_url(public.plg_blog_html_attr(v_tag, 'href'));
716
+ IF v_val IS NOT NULL THEN
717
+ -- An external publisher's outbound links are not the tenant's
718
+ -- endorsement, and an unmarked link farm is precisely the SEO damage
719
+ -- this seam could otherwise cause on the tenant's own domain.
720
+ v_attrs := ' href="' || v_val || '" rel="nofollow noopener"';
721
+ END IF;
722
+ ELSIF v_name = 'img' THEN
723
+ v_val := public.plg_blog_safe_url(public.plg_blog_html_attr(v_tag, 'src'));
724
+ IF v_val IS NULL THEN CONTINUE; END IF; -- an <img> with no safe src is nothing
725
+ v_attrs := ' src="' || v_val || '" alt="'
726
+ || public.plg_blog_html_escape(public.plg_blog_html_attr(v_tag, 'alt')) || '"';
727
+ END IF;
728
+
729
+ v_out := v_out || '<' || v_name || v_attrs
730
+ || CASE WHEN v_name = ANY (c_void) THEN ' />' ELSE '>' END;
731
+ END LOOP;
732
+
733
+ RETURN v_out;
734
+ END;
735
+ $fn$;
736
+
737
+ -- Accents are transliterated rather than stripped: 'sa\xFAde-bucal' beats
738
+ -- 'sa-de-bucal', and a slug is a URL somebody will read out loud.
739
+ CREATE OR REPLACE FUNCTION public.plg_blog_slugify(p_text text)
740
+ RETURNS text
741
+ LANGUAGE sql
742
+ IMMUTABLE
743
+ AS $fn$
744
+ SELECT btrim(
745
+ regexp_replace(
746
+ translate(lower(coalesce(p_text, '')),
747
+ '\xE1\xE0\xE2\xE3\xE4\xE5\xE9\xE8\xEA\xEB\xED\xEC\xEE\xEF\xF3\xF2\xF4\xF5\xF6\xF8\xFA\xF9\xFB\xFC\xE7\xF1\xFD\xFF',
748
+ 'aaaaaaeeeeiiiioooooouuuucnyy'),
749
+ '[^a-z0-9]+', '-', 'g'),
750
+ '-');
751
+ $fn$;
752
+
753
+ REVOKE ALL ON FUNCTION public.plg_blog_html_attr(text, text) FROM public, anon;
754
+ REVOKE ALL ON FUNCTION public.plg_blog_html_escape(text) FROM public, anon;
755
+ REVOKE ALL ON FUNCTION public.plg_blog_safe_url(text) FROM public, anon;
756
+ REVOKE ALL ON FUNCTION public.plg_blog_sanitize_html(text) FROM public, anon;
757
+ REVOKE ALL ON FUNCTION public.plg_blog_slugify(text) FROM public, anon;
758
+ GRANT EXECUTE ON FUNCTION public.plg_blog_html_attr(text, text) TO authenticated, service_role;
759
+ GRANT EXECUTE ON FUNCTION public.plg_blog_html_escape(text) TO authenticated, service_role;
760
+ GRANT EXECUTE ON FUNCTION public.plg_blog_safe_url(text) TO authenticated, service_role;
761
+ GRANT EXECUTE ON FUNCTION public.plg_blog_sanitize_html(text) TO authenticated, service_role;
762
+ GRANT EXECUTE ON FUNCTION public.plg_blog_slugify(text) TO authenticated, service_role;
763
+
764
+ -- \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
765
+ -- The one door. Takes a single jsonb so the caller never has to know a column
766
+ -- list, and returns what it did so a connector can reconcile without a second
767
+ -- read.
768
+ --
769
+ -- Payload: external_ref, title, slug, excerpt, body_html, status, source,
770
+ -- language, tags[], author_member_id, published_at, cover_image, meta_title,
771
+ -- meta_description, canonical_url, og_image, noindex.
772
+ CREATE OR REPLACE FUNCTION public.blog_upsert_external_post(p_payload jsonb)
773
+ RETURNS jsonb
774
+ LANGUAGE plpgsql
775
+ SECURITY DEFINER
776
+ SET search_path = public
777
+ AS $fn$
778
+ DECLARE
779
+ v_tenants uuid[];
780
+ v_tenant uuid;
781
+ v_claimed uuid;
782
+ v_ref text;
783
+ v_title text;
784
+ v_base text;
785
+ v_slug text;
786
+ v_status text;
787
+ v_source text;
788
+ v_lang text;
789
+ v_body text;
790
+ v_excerpt text;
791
+ v_tags text[];
792
+ v_author uuid;
793
+ v_pub timestamptz;
794
+ v_cover text;
795
+ v_existing_id uuid;
796
+ v_existing_pub timestamptz;
797
+ v_existing_slug text;
798
+ v_n int := 1;
799
+ v_id uuid;
800
+ v_created boolean := false;
801
+ BEGIN
802
+ -- \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
803
+ -- A tenant id the caller supplies is how one tenant writes into another's
804
+ -- blog, and the caller is a machine outside our trust boundary. The only
805
+ -- statement about identity we can believe is the one the credential makes.
806
+ SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
807
+ IF v_tenants IS NULL OR array_length(v_tenants, 1) <> 1 THEN
808
+ RAISE EXCEPTION
809
+ 'blog_upsert_external_post: the caller must present a credential scoped to exactly one tenant'
810
+ USING ERRCODE = 'insufficient_privilege';
811
+ END IF;
812
+ v_tenant := v_tenants[1];
813
+
814
+ -- Refusing beats ignoring: a publisher that believes it is writing into
815
+ -- another tenant must be told so, not silently redirected into this one.
816
+ IF nullif(btrim(p_payload->>'tenant_id'), '') IS NOT NULL THEN
817
+ BEGIN
818
+ v_claimed := (p_payload->>'tenant_id')::uuid;
819
+ EXCEPTION WHEN OTHERS THEN
820
+ v_claimed := NULL;
821
+ END;
822
+ IF v_claimed IS DISTINCT FROM v_tenant THEN
823
+ RAISE EXCEPTION 'blog_upsert_external_post: payload names another tenant; the tenant comes from the credential'
824
+ USING ERRCODE = 'insufficient_privilege';
825
+ END IF;
826
+ END IF;
827
+
828
+ -- \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
829
+ v_ref := nullif(btrim(p_payload->>'external_ref'), '');
830
+ v_title := nullif(btrim(p_payload->>'title'), '');
831
+ IF v_title IS NULL THEN
832
+ RAISE EXCEPTION 'blog_upsert_external_post: title is required'
833
+ USING ERRCODE = 'invalid_parameter_value';
834
+ END IF;
835
+
836
+ v_status := lower(coalesce(nullif(btrim(p_payload->>'status'), ''), 'draft'));
837
+ IF v_status NOT IN ('draft', 'published') THEN
838
+ RAISE EXCEPTION 'blog_upsert_external_post: unknown status %; expected draft or published', v_status
839
+ USING ERRCODE = 'invalid_parameter_value';
840
+ END IF;
841
+
842
+ -- Never taken as 'admin' by default: a post that arrived through this door is
843
+ -- external by definition, and mislabelling it is what makes a connector echo
844
+ -- it back to the system that sent it.
845
+ v_source := lower(coalesce(nullif(btrim(p_payload->>'source'), ''), 'external'));
846
+ v_lang := nullif(btrim(p_payload->>'language'), '');
847
+ v_body := public.plg_blog_sanitize_html(p_payload->>'body_html');
848
+ v_excerpt := left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500);
849
+
850
+ IF jsonb_typeof(p_payload->'tags') = 'array' THEN
851
+ SELECT array_agg(btrim(x)) INTO v_tags
852
+ FROM jsonb_array_elements_text(p_payload->'tags') AS x
853
+ WHERE btrim(x) <> '';
854
+ END IF;
855
+
856
+ -- An author id is a claim about a person in THIS tenant. Unverified, it would
857
+ -- let an external publisher put a real employee's name on anything it writes.
858
+ IF nullif(btrim(p_payload->>'author_member_id'), '') IS NOT NULL THEN
859
+ BEGIN
860
+ SELECT tm.id INTO v_author
861
+ FROM public.tenant_members tm
862
+ WHERE tm.tenant_id = v_tenant AND tm.id = (p_payload->>'author_member_id')::uuid;
863
+ EXCEPTION WHEN OTHERS THEN
864
+ v_author := NULL;
865
+ END;
866
+ END IF;
867
+
868
+ v_cover := public.plg_blog_safe_url(
869
+ coalesce(nullif(btrim(p_payload->>'cover_image'), ''), nullif(btrim(p_payload->>'og_image'), '')));
870
+
871
+ -- \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
872
+ IF v_ref IS NOT NULL THEN
873
+ SELECT p.id, p.published_at, p.slug INTO v_existing_id, v_existing_pub, v_existing_slug
874
+ FROM public.plg_blog_posts p
875
+ WHERE p.tenant_id = v_tenant AND p.external_ref = v_ref;
876
+ END IF;
877
+
878
+ -- On re-delivery the existing slug WINS over one re-derived from the title.
879
+ -- A published post's slug is its URL: silently re-slugging it because the
880
+ -- publisher fixed a typo in the headline breaks every inbound link \u2014 on an
881
+ -- SEO product's own articles, of all things. Changing it stays possible, but
882
+ -- only by saying so explicitly in \`slug\`.
883
+ v_base := coalesce(
884
+ nullif(public.plg_blog_slugify(p_payload->>'slug'), ''),
885
+ nullif(v_existing_slug, ''),
886
+ nullif(public.plg_blog_slugify(v_title), ''),
887
+ 'post-' || left(md5(coalesce(v_ref, gen_random_uuid()::text)), 8));
888
+ v_base := left(v_base, 120);
889
+
890
+ -- Slug is unique per tenant (001). Two different articles that slugify the
891
+ -- same must not make the second delivery fail \u2014 a suffixed slug is a smaller
892
+ -- surprise than a rejected publish.
893
+ v_slug := v_base;
894
+ WHILE EXISTS (
895
+ SELECT 1 FROM public.plg_blog_posts p
896
+ WHERE p.tenant_id = v_tenant AND p.slug = v_slug
897
+ AND (v_existing_id IS NULL OR p.id <> v_existing_id)
898
+ ) LOOP
899
+ v_n := v_n + 1;
900
+ IF v_n > 50 THEN
901
+ RAISE EXCEPTION 'blog_upsert_external_post: cannot derive a free slug from %', v_base
902
+ USING ERRCODE = 'invalid_parameter_value';
903
+ END IF;
904
+ v_slug := left(v_base, 112) || '-' || v_n;
905
+ END LOOP;
906
+
907
+ BEGIN
908
+ v_pub := nullif(btrim(p_payload->>'published_at'), '')::timestamptz;
909
+ EXCEPTION WHEN OTHERS THEN
910
+ v_pub := NULL;
911
+ END;
912
+ -- A post that goes back to draft keeps the date it first went out; the public
913
+ -- view filters on status, so nothing leaks, and the history stays true.
914
+ v_pub := coalesce(v_pub, v_existing_pub, CASE WHEN v_status = 'published' THEN now() END);
915
+
916
+ -- \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
917
+ IF v_existing_id IS NOT NULL THEN
918
+ UPDATE public.plg_blog_posts SET
919
+ slug = v_slug,
920
+ title = v_title,
921
+ excerpt = v_excerpt,
922
+ body = v_body,
923
+ status = v_status,
924
+ published_at = v_pub,
925
+ source = v_source,
926
+ language = v_lang,
927
+ tags = v_tags,
928
+ author_member_id = coalesce(v_author, author_member_id),
929
+ cover_image = coalesce(v_cover, cover_image),
930
+ meta_title = nullif(btrim(p_payload->>'meta_title'), ''),
931
+ meta_description = nullif(btrim(p_payload->>'meta_description'), ''),
932
+ canonical_url = public.plg_blog_safe_url(p_payload->>'canonical_url'),
933
+ og_image = public.plg_blog_safe_url(p_payload->>'og_image'),
934
+ noindex = coalesce((p_payload->>'noindex')::boolean, false),
935
+ updated_at = now()
936
+ WHERE id = v_existing_id;
937
+ v_id := v_existing_id;
938
+ ELSE
939
+ -- ON CONFLICT on the partial index, not just the SELECT above: two
940
+ -- deliveries of the same article racing each other would both find nothing
941
+ -- and both insert. The index is what makes the second one an update.
942
+ INSERT INTO public.plg_blog_posts (
943
+ tenant_id, slug, title, excerpt, body, status, published_at,
944
+ external_ref, source, language, tags, author_member_id, cover_image,
945
+ meta_title, meta_description, canonical_url, og_image, noindex, updated_at)
946
+ VALUES (
947
+ v_tenant, v_slug, v_title, v_excerpt, v_body, v_status, v_pub,
948
+ v_ref, v_source, v_lang, v_tags, v_author, v_cover,
949
+ nullif(btrim(p_payload->>'meta_title'), ''),
950
+ nullif(btrim(p_payload->>'meta_description'), ''),
951
+ public.plg_blog_safe_url(p_payload->>'canonical_url'),
952
+ public.plg_blog_safe_url(p_payload->>'og_image'),
953
+ coalesce((p_payload->>'noindex')::boolean, false),
954
+ now())
955
+ ON CONFLICT (tenant_id, external_ref) WHERE external_ref IS NOT NULL
956
+ DO UPDATE SET
957
+ title = excluded.title,
958
+ excerpt = excluded.excerpt,
959
+ body = excluded.body,
960
+ status = excluded.status,
961
+ published_at = coalesce(excluded.published_at, plg_blog_posts.published_at),
962
+ source = excluded.source,
963
+ language = excluded.language,
964
+ tags = excluded.tags,
965
+ cover_image = coalesce(excluded.cover_image, plg_blog_posts.cover_image),
966
+ meta_title = excluded.meta_title,
967
+ meta_description = excluded.meta_description,
968
+ canonical_url = excluded.canonical_url,
969
+ og_image = excluded.og_image,
970
+ noindex = excluded.noindex,
971
+ updated_at = now()
972
+ RETURNING id, (xmax = 0) INTO v_id, v_created;
973
+ END IF;
974
+
975
+ RETURN jsonb_build_object(
976
+ 'ok', true,
977
+ 'id', v_id,
978
+ 'slug', v_slug,
979
+ 'status', v_status,
980
+ 'source', v_source,
981
+ 'external_ref', v_ref,
982
+ 'created', coalesce(v_created, false));
983
+ END;
984
+ $fn$;
985
+
986
+ REVOKE ALL ON FUNCTION public.blog_upsert_external_post(jsonb) FROM public, anon;
987
+ GRANT EXECUTE ON FUNCTION public.blog_upsert_external_post(jsonb) TO authenticated, service_role;
988
+
989
+ -- \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
990
+ -- The assistant's write, in the shape the broker calls (p_tenant_id,
991
+ -- p_actor_user_id, p_payload) \u2014 a different signature from \xA74 on purpose: the
992
+ -- broker injects a verified actor and agent_guard runs role \u2192 plan \u2192 the
993
+ -- blog_posts cap before anything is written. A draft, never a publish: an agent
994
+ -- that can put a page on the public site without anyone reading it first is a
995
+ -- worse product than one that cannot.
996
+ CREATE OR REPLACE FUNCTION public.agent_blog_draft_post(
997
+ p_tenant_id uuid,
998
+ p_actor_user_id uuid,
999
+ p_payload jsonb
1000
+ ) RETURNS jsonb
1001
+ LANGUAGE plpgsql
1002
+ SECURITY DEFINER
1003
+ SET search_path = public
1004
+ AS $fn$
1005
+ DECLARE
1006
+ v_denial jsonb;
1007
+ v_used int;
1008
+ v_title text;
1009
+ v_base text;
1010
+ v_slug text;
1011
+ v_n int := 1;
1012
+ v_id uuid;
1013
+ BEGIN
1014
+ v_title := nullif(btrim(p_payload->>'title'), '');
1015
+ IF v_title IS NULL THEN
1016
+ RETURN jsonb_build_object('ok', false, 'error', 'title is required to draft a post');
1017
+ END IF;
1018
+
1019
+ SELECT count(*) INTO v_used FROM public.plg_blog_posts p WHERE p.tenant_id = p_tenant_id;
1020
+ v_denial := public.agent_guard(p_tenant_id, p_actor_user_id, 'blog', 'create', 'blog_posts', v_used, 1);
1021
+ IF v_denial IS NOT NULL THEN
1022
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
1023
+ END IF;
1024
+
1025
+ v_base := coalesce(nullif(public.plg_blog_slugify(v_title), ''), 'post-' || left(md5(v_title), 8));
1026
+ v_slug := left(v_base, 120);
1027
+ WHILE EXISTS (SELECT 1 FROM public.plg_blog_posts p
1028
+ WHERE p.tenant_id = p_tenant_id AND p.slug = v_slug) LOOP
1029
+ v_n := v_n + 1;
1030
+ IF v_n > 50 THEN
1031
+ RETURN jsonb_build_object('ok', false, 'error', format('cannot derive a free slug from %L', v_base));
1032
+ END IF;
1033
+ v_slug := left(v_base, 112) || '-' || v_n;
1034
+ END LOOP;
1035
+
1036
+ INSERT INTO public.plg_blog_posts (
1037
+ tenant_id, slug, title, excerpt, body, status, source, language, tags, updated_at)
1038
+ VALUES (
1039
+ p_tenant_id, v_slug, v_title,
1040
+ left(btrim(regexp_replace(coalesce(p_payload->>'excerpt', ''), '<[^>]*>', '', 'g')), 500),
1041
+ -- Same sanitiser as the external door: the model's output is not more
1042
+ -- trustworthy than a partner's, it is just closer to home.
1043
+ public.plg_blog_sanitize_html(p_payload->>'body_html'),
1044
+ 'draft', 'agent',
1045
+ nullif(btrim(p_payload->>'language'), ''),
1046
+ CASE WHEN jsonb_typeof(p_payload->'tags') = 'array'
1047
+ THEN ARRAY(SELECT btrim(x) FROM jsonb_array_elements_text(p_payload->'tags') AS x WHERE btrim(x) <> '')
1048
+ END,
1049
+ now())
1050
+ RETURNING id INTO v_id;
1051
+
1052
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
1053
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.draftBlogPost', 'blog_post', v_id::text,
1054
+ jsonb_build_object('title', v_title, 'slug', v_slug));
1055
+
1056
+ RETURN jsonb_build_object(
1057
+ 'ok', true,
1058
+ 'id', v_id,
1059
+ 'record', jsonb_build_object(
1060
+ 'ref', jsonb_build_object('id', v_id, 'resource', 'plg_blog_posts', 'entityKey', 'blog:posts'),
1061
+ 'name', v_title,
1062
+ 'slug', v_slug,
1063
+ 'status', 'draft'));
1064
+ END;
1065
+ $fn$;
1066
+
1067
+ REVOKE ALL ON FUNCTION public.agent_blog_draft_post(uuid, uuid, jsonb) FROM public, anon;
1068
+ GRANT EXECUTE ON FUNCTION public.agent_blog_draft_post(uuid, uuid, jsonb) TO authenticated, service_role;
1069
+
1070
+ -- \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
1071
+ -- One trigger on the table rather than an emit call inside each writer. The
1072
+ -- backoffice, this file's two RPCs and a hand-run SQL fix all end up in the
1073
+ -- same place, so a connector hears the same four facts no matter who wrote \u2014
1074
+ -- which is the entire reason the row-level emitter exists (packages/db/
1075
+ -- migrations/027_domain_events.sql).
1076
+ --
1077
+ -- \`published\` is not a row action, which is why the generic
1078
+ -- plg_attach_event_source trigger cannot produce it: it is a TRANSITION, and
1079
+ -- only something holding OLD and NEW can see it happen.
1080
+ CREATE OR REPLACE FUNCTION public.plg_blog_emit_post_event()
1081
+ RETURNS trigger
1082
+ LANGUAGE plpgsql
1083
+ SECURITY DEFINER
1084
+ SET search_path = public
1085
+ AS $fn$
1086
+ DECLARE
1087
+ v_row jsonb;
1088
+ v_tenant uuid;
1089
+ v_id text;
1090
+ v_payload jsonb;
1091
+ BEGIN
1092
+ -- A save that changed nothing is not a fact; without this any bulk re-save
1093
+ -- buries the real updates.
1094
+ IF TG_OP = 'UPDATE' AND OLD IS NOT DISTINCT FROM NEW THEN
1095
+ RETURN NULL;
1096
+ END IF;
1097
+
1098
+ IF TG_OP = 'DELETE' THEN
1099
+ v_row := to_jsonb(OLD);
1100
+ ELSE
1101
+ v_row := to_jsonb(NEW);
1102
+ END IF;
1103
+ v_tenant := nullif(v_row->>'tenant_id', '')::uuid;
1104
+ v_id := v_row->>'id';
1105
+ -- The contract every consumer reads. Not the row: a whole-row payload puts
1106
+ -- the article body inside every webhook by default, and whoever needs it
1107
+ -- fetches by id.
1108
+ v_payload := jsonb_build_object(
1109
+ 'post_id', v_id,
1110
+ 'slug', v_row->>'slug',
1111
+ 'title', v_row->>'title',
1112
+ 'status', v_row->>'status',
1113
+ 'source', coalesce(v_row->>'source', 'admin'),
1114
+ 'external_ref', v_row->>'external_ref',
1115
+ 'language', v_row->>'language');
1116
+
1117
+ BEGIN
1118
+ IF TG_OP = 'INSERT' THEN
1119
+ PERFORM public.plg_emit_event('blog.post.created', v_payload, 'blog_post', v_id, v_tenant, NULL);
1120
+ IF NEW.status = 'published' THEN
1121
+ PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
1122
+ END IF;
1123
+ ELSIF TG_OP = 'UPDATE' THEN
1124
+ PERFORM public.plg_emit_event('blog.post.updated', v_payload, 'blog_post', v_id, v_tenant, NULL);
1125
+ IF NEW.status = 'published' AND OLD.status IS DISTINCT FROM 'published' THEN
1126
+ PERFORM public.plg_emit_event('blog.post.published', v_payload, 'blog_post', v_id, v_tenant, NULL);
1127
+ END IF;
1128
+ ELSE
1129
+ PERFORM public.plg_emit_event('blog.post.deleted', v_payload, 'blog_post', v_id, v_tenant, NULL);
1130
+ END IF;
1131
+ EXCEPTION WHEN OTHERS THEN
1132
+ -- Observability must never be able to reject the write that caused it.
1133
+ RAISE WARNING '[plg_blog_emit_post_event] % on %: %', TG_OP, TG_TABLE_NAME, SQLERRM;
1134
+ END;
1135
+
1136
+ RETURN NULL;
1137
+ END;
1138
+ $fn$;
1139
+
1140
+ REVOKE ALL ON FUNCTION public.plg_blog_emit_post_event() FROM public, anon;
1141
+
1142
+ DROP TRIGGER IF EXISTS plg_blog_posts_emit_facts ON public.plg_blog_posts;
1143
+ CREATE TRIGGER plg_blog_posts_emit_facts
1144
+ AFTER INSERT OR UPDATE OR DELETE ON public.plg_blog_posts
1145
+ FOR EACH ROW EXECUTE FUNCTION public.plg_blog_emit_post_event();
1146
+
1147
+ -- \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
1148
+ -- The public site runs anon and reads only this view, so per-post SEO that the
1149
+ -- view does not carry cannot be rendered \u2014 the columns would exist and no page
1150
+ -- would ever show them.
1151
+ --
1152
+ -- \`source\` and \`external_ref\` stay out: they are operational facts about who
1153
+ -- writes this tenant's blog, and the anon surface is the open internet.
1154
+ -- New columns are appended, never reordered \u2014 CREATE OR REPLACE VIEW only
1155
+ -- tolerates additions at the end.
1156
+ CREATE OR REPLACE VIEW public.v_public_blog_posts AS
1157
+ SELECT
1158
+ p.id,
1159
+ p.tenant_id,
1160
+ p.slug,
1161
+ p.title,
1162
+ p.excerpt,
1163
+ p.body,
1164
+ COALESCE(c.name, '') AS tag,
1165
+ p.cover_image,
1166
+ p.author_name,
1167
+ p.author_role,
1168
+ p.author_avatar_url,
1169
+ p.author_bio,
1170
+ p.read_time,
1171
+ p.published_at,
1172
+ p.meta_title,
1173
+ p.meta_description,
1174
+ p.canonical_url,
1175
+ p.og_image,
1176
+ p.noindex,
1177
+ p.language,
1178
+ p.tags
1179
+ FROM public.plg_blog_posts p
1180
+ LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
1181
+ WHERE p.status = 'published';
1182
+
471
1183
  GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
472
1184
  `;
473
1185
  var MIGRATIONS = [
474
- { id: "001_blog", sql: MIGRATION_001_BLOG }
1186
+ { id: "001_blog", sql: MIGRATION_001_BLOG },
1187
+ { id: "002_external_posts", sql: MIGRATION_002_EXTERNAL_POSTS }
475
1188
  ];
476
1189
 
1190
+ // src/data/runtime.ts
1191
+ var _provider = null;
1192
+ function setBlogProvider(provider) {
1193
+ _provider = provider;
1194
+ }
1195
+ function getBlogProvider() {
1196
+ if (!_provider) {
1197
+ throw new Error(
1198
+ "[plugin-blog] no blog provider registered \u2014 call createBlogPlugin() before resolving blog loaders, and mount its Provider at the app root."
1199
+ );
1200
+ }
1201
+ return _provider;
1202
+ }
1203
+ function hasBlogProvider() {
1204
+ return _provider !== null;
1205
+ }
1206
+ function resetBlogProvider() {
1207
+ _provider = null;
1208
+ }
1209
+ var BLOG_POSTS_LOADER = "blog.posts";
1210
+ var BLOG_POST_LOADER = "blog.post";
1211
+ async function blogPosts(params) {
1212
+ const { limit, tag } = params;
1213
+ return getBlogProvider().listPosts({ limit, tag });
1214
+ }
1215
+ async function blogPost(params) {
1216
+ const slug = typeof params.slug === "string" ? params.slug : void 0;
1217
+ if (!slug) return null;
1218
+ return getBlogProvider().getPost(slug);
1219
+ }
1220
+ function registerBlogLoaders() {
1221
+ if (!hasLoader(BLOG_POSTS_LOADER)) {
1222
+ defineLoader(BLOG_POSTS_LOADER, blogPosts, {
1223
+ source: "plugin",
1224
+ title: "Blog posts",
1225
+ description: 'Published posts, newest first. The read behind a blog index, a "latest articles" rail, or a tag archive.',
1226
+ output: "Array of BlogPost (slug, title, excerpt, body, image, tag, author, publishedAt \u2026).",
1227
+ settings: [
1228
+ {
1229
+ id: "limit",
1230
+ type: "number",
1231
+ label: "How many",
1232
+ help: "Empty means every published post.",
1233
+ min: 1,
1234
+ max: 100
1235
+ },
1236
+ {
1237
+ id: "tag",
1238
+ type: "text",
1239
+ label: "Tag",
1240
+ help: "Only posts carrying this tag. Empty means all posts."
1241
+ }
1242
+ ]
1243
+ });
1244
+ }
1245
+ if (!hasLoader(BLOG_POST_LOADER)) {
1246
+ defineLoader(BLOG_POST_LOADER, blogPost, {
1247
+ source: "plugin",
1248
+ title: "Blog post",
1249
+ description: "One post by slug. The read behind a post page.",
1250
+ output: "A BlogPost, or null when no post has that slug.",
1251
+ settings: [
1252
+ {
1253
+ id: "slug",
1254
+ type: "text",
1255
+ label: "Slug",
1256
+ help: 'Usually bound to the route param, e.g. `{ $param: "slug" }`.'
1257
+ }
1258
+ ]
1259
+ });
1260
+ }
1261
+ }
1262
+
1263
+ // src/lib/blog-events.ts
1264
+ var BLOG_POST_EVENT_FIELDS = [
1265
+ { name: "post_id", label: "ID do post", type: "id", example: "8f14e45f-\u2026" },
1266
+ { name: "slug", label: "Endere\xE7o", type: "string", example: "saude-bucal-na-terceira-idade" },
1267
+ { name: "title", label: "T\xEDtulo", type: "string", example: "Sa\xFAde bucal na terceira idade" },
1268
+ { name: "status", label: "Situa\xE7\xE3o", type: "string", example: "published" },
1269
+ { name: "source", label: "Origem", type: "string", example: "ranklayer" },
1270
+ { name: "external_ref", label: "Refer\xEAncia externa", type: "string", example: "rl-article-77" },
1271
+ { name: "language", label: "Idioma", type: "string", example: "pt-BR" }
1272
+ ];
1273
+ var PAYLOAD_SCHEMA = {
1274
+ type: "object",
1275
+ properties: {
1276
+ post_id: { type: "string", format: "uuid", description: "Post id in the tenant" },
1277
+ slug: { type: "string", description: "URL segment under the blog base path" },
1278
+ title: { type: "string" },
1279
+ status: { type: "string", enum: ["draft", "published"] },
1280
+ source: {
1281
+ type: "string",
1282
+ description: "Who wrote the post \u2014 'admin', 'ranklayer', 'agent'. A connector compares it against its own id to avoid echoing a post back to the system it came from."
1283
+ },
1284
+ external_ref: { type: "string", description: "The writing system's own id for the article, when it has one" },
1285
+ language: { type: "string", description: "BCP-47 tag, when the writer declares one" }
1286
+ },
1287
+ required: ["post_id", "slug", "title", "status", "source"],
1288
+ additionalProperties: false
1289
+ };
1290
+ function buildBlogDomainEvents() {
1291
+ return [
1292
+ {
1293
+ name: "blog.post.created",
1294
+ title: "Post criado",
1295
+ description: "Um artigo novo entrou no blog",
1296
+ trigger: "Quando um post \xE9 criado \u2014 pelo painel, pelo assistente ou por um publicador externo",
1297
+ icon: "FilePlus",
1298
+ audit: true,
1299
+ fields: BLOG_POST_EVENT_FIELDS,
1300
+ payloadSchema: PAYLOAD_SCHEMA
1301
+ },
1302
+ {
1303
+ name: "blog.post.updated",
1304
+ title: "Post alterado",
1305
+ description: "O conte\xFAdo ou os metadados do artigo mudaram",
1306
+ trigger: "Quando um post \xE9 salvo com alguma altera\xE7\xE3o de verdade",
1307
+ icon: "FilePen",
1308
+ audit: false,
1309
+ fields: BLOG_POST_EVENT_FIELDS,
1310
+ payloadSchema: PAYLOAD_SCHEMA
1311
+ },
1312
+ {
1313
+ name: "blog.post.published",
1314
+ title: "Post publicado",
1315
+ description: "O artigo ficou vis\xEDvel no site",
1316
+ trigger: "Quando o post passa a publicado \u2014 na cria\xE7\xE3o ou saindo de rascunho",
1317
+ icon: "Globe",
1318
+ audit: true,
1319
+ fields: BLOG_POST_EVENT_FIELDS,
1320
+ payloadSchema: PAYLOAD_SCHEMA
1321
+ },
1322
+ {
1323
+ name: "blog.post.deleted",
1324
+ title: "Post exclu\xEDdo",
1325
+ description: "O artigo saiu do ar",
1326
+ trigger: "Quando um post \xE9 exclu\xEDdo",
1327
+ icon: "FileX",
1328
+ audit: true,
1329
+ fields: BLOG_POST_EVENT_FIELDS,
1330
+ payloadSchema: PAYLOAD_SCHEMA
1331
+ }
1332
+ ];
1333
+ }
1334
+
477
1335
  // src/index.ts
1336
+ var MIGRATION_META = {
1337
+ "001_blog": {
1338
+ version: "1.0.0",
1339
+ description: "Blog content model (plg_blog_categories, plg_blog_posts) + anon-read view"
1340
+ },
1341
+ "002_external_posts": {
1342
+ version: "1.1.0",
1343
+ description: "Per-post SEO, external identity (external_ref/source/language/tags), blog_upsert_external_post + agent_blog_draft_post, and the blog.post.* triggers"
1344
+ }
1345
+ };
478
1346
  function createBlogPlugin(options) {
479
1347
  const config = resolveConfig(options);
480
1348
  const provider = options?.dataProvider ?? createSafeDataProvider(
481
1349
  () => createSupabaseBlogProvider({ tenantId: options?.tenantId }),
482
1350
  () => createMockBlogProvider({ seed: options?.seed, defaultAuthor: options?.defaultAuthor })
483
1351
  );
1352
+ setBlogProvider(provider);
1353
+ registerBlogLoaders();
484
1354
  const value = { provider, config };
485
1355
  const Provider = ({ children }) => createElement(BlogProvider, { value, children });
486
1356
  Provider.displayName = "BlogPluginProvider";
@@ -488,7 +1358,10 @@ function createBlogPlugin(options) {
488
1358
  id: "blog",
489
1359
  name: "Blog",
490
1360
  icon: "Newspaper",
491
- version: "0.1.0",
1361
+ // Tracks package.json. It sat at '0.1.0' for nine minor releases, and a
1362
+ // partner reading the manifest concluded the plugin was a stub.
1363
+ version: "0.10.0",
1364
+ apiVersion: 1,
492
1365
  scope: options?.scope ?? "universal",
493
1366
  verticalId: options?.verticalId,
494
1367
  scaffolds: ["website", "landing_page"],
@@ -498,6 +1371,98 @@ function createBlogPlugin(options) {
498
1371
  // Blog posts count against the plan cap. The create UI lives in the
499
1372
  // marketing "Blog" backoffice, which writes to this plugin's posts table.
500
1373
  declaredLimits: [{ key: "blog_posts", label: "Blog posts", table: "plg_blog_posts" }],
1374
+ // The facts. Emitted by a trigger on the posts table (migration 002 §6), so
1375
+ // an external publisher's write produces the same event as the backoffice's.
1376
+ events: buildBlogDomainEvents(),
1377
+ // The generic row-level ledger coverage (registry.blog_post.*), which is a
1378
+ // different vocabulary from the four above on purpose: it says a ROW
1379
+ // changed, the events say what happened to a POST. Nothing attaches this
1380
+ // yet — plg_attach_event_source is wired in a later milestone.
1381
+ emitsFrom: [{ table: "plg_blog_posts", entityKey: "blog_post" }],
1382
+ declaredRpcs: [
1383
+ {
1384
+ name: "blog_upsert_external_post",
1385
+ kind: "write",
1386
+ description: "Ingest door for an external publisher (RankLayer). Tenant pinned from the caller credential, idempotent on (tenant_id, external_ref), body_html sanitised against a tag allowlist in SQL.",
1387
+ audits: false
1388
+ },
1389
+ {
1390
+ name: "agent_blog_draft_post",
1391
+ kind: "write",
1392
+ description: "Guarded blog draft: agent_guard (role \u2192 plan \u2192 blog_posts cap), body sanitised by the same allowlist, always status draft, audited.",
1393
+ audits: true
1394
+ }
1395
+ ],
1396
+ // Without this the blog is invisible to the agent: it carries its own data
1397
+ // provider, and only queryEntities reaches the server-plane executor.
1398
+ queryEntities: [
1399
+ {
1400
+ key: "blog:posts",
1401
+ entity: {
1402
+ name: "Post",
1403
+ namePlural: "Posts",
1404
+ icon: "Newspaper",
1405
+ permission: { feature: "blog", action: "read" },
1406
+ fields: [
1407
+ { key: "title", label: "T\xEDtulo", type: "text", searchable: true },
1408
+ { key: "slug", label: "Endere\xE7o", type: "text", searchable: true },
1409
+ { key: "status", label: "Situa\xE7\xE3o", type: "text" },
1410
+ { key: "source", label: "Origem", type: "text" },
1411
+ { key: "language", label: "Idioma", type: "text" },
1412
+ { key: "published_at", label: "Publicado em", type: "text" },
1413
+ { key: "updated_at", label: "Atualizado em", type: "text" }
1414
+ ],
1415
+ data: {
1416
+ table: "plg_blog_posts",
1417
+ tenantScoped: true,
1418
+ searchColumns: ["title", "slug"]
1419
+ }
1420
+ }
1421
+ }
1422
+ ],
1423
+ aiTools: [
1424
+ {
1425
+ id: "blog.list-posts",
1426
+ name: "listBlogPosts",
1427
+ description: "Lists the blog posts of this tenant \u2014 title, slug, status, source and dates. Use it to answer what has been published, what is still a draft, and what came from an external publisher.",
1428
+ icon: "Newspaper",
1429
+ mode: "read",
1430
+ execution: { plane: "server", kind: "entity_read", entity: "blog:posts" },
1431
+ category: "Blog",
1432
+ parameters: {
1433
+ type: "object",
1434
+ properties: {
1435
+ status: { type: "string", enum: ["draft", "published"] },
1436
+ search: { type: "string", description: "Match against title or slug" }
1437
+ }
1438
+ },
1439
+ suggestions: [{ label: "Quais posts est\xE3o em rascunho?" }, { label: "O que foi publicado no blog este m\xEAs?" }],
1440
+ permission: { feature: "blog", action: "read" }
1441
+ },
1442
+ {
1443
+ id: "blog.draft-post",
1444
+ name: "draftBlogPost",
1445
+ description: "Creates a blog post as a DRAFT \u2014 it never goes live from here; a person publishes it from the blog backoffice. Takes a title and the article body as simple HTML (headings, paragraphs, lists, links).",
1446
+ icon: "FilePlus",
1447
+ mode: "persist",
1448
+ limitKey: "blog_posts",
1449
+ execution: { plane: "server", kind: "rpc", rpc: "agent_blog_draft_post" },
1450
+ category: "Blog",
1451
+ parameters: {
1452
+ type: "object",
1453
+ properties: {
1454
+ title: { type: "string", description: "Post title" },
1455
+ body_html: { type: "string", description: "Article body as simple HTML" },
1456
+ excerpt: { type: "string", description: "One-line summary shown on cards" },
1457
+ language: { type: "string", description: "BCP-47 tag, e.g. pt-BR" },
1458
+ tags: { type: "array", items: { type: "string" }, description: "Topic tags" }
1459
+ },
1460
+ required: ["title"]
1461
+ },
1462
+ suggestions: [{ label: "Escreve um rascunho de post sobre clareamento dental" }],
1463
+ permission: { feature: "blog", action: "create" }
1464
+ }
1465
+ ],
501
1466
  // Host owns website nav; no admin nav in M1.
502
1467
  navigation: [],
503
1468
  routes: [
@@ -505,18 +1470,20 @@ function createBlogPlugin(options) {
505
1470
  { path: `${config.basePath}/:slug`, component: PostDetail, guard: "public" }
506
1471
  ],
507
1472
  widgets: [],
1473
+ // Measuring what the posts brought in, and pushing a new one anywhere else.
1474
+ integrations: ["google-analytics", "zapier"],
508
1475
  // Owns the blog domain tables (categories + posts + anon-read view). The
509
1476
  // marketing "Blog" backoffice writes to these; the website reads them.
510
1477
  migrations: MIGRATIONS.map((m) => ({
511
1478
  id: `blog-${m.id}`,
512
- version: "1.0.0",
1479
+ version: MIGRATION_META[m.id]?.version ?? "1.0.0",
513
1480
  sql: m.sql,
514
- description: "Blog content model (plg_blog_categories, plg_blog_posts) + anon-read view"
1481
+ description: MIGRATION_META[m.id]?.description ?? m.id
515
1482
  }))
516
1483
  };
517
1484
  return { manifest, Provider, dataProvider: provider };
518
1485
  }
519
1486
 
520
- export { AuthorAvatar, BlogList, BlogProvider, PostCard, PostDetail, createBlogPlugin, createMockBlogProvider, createSupabaseBlogProvider, useBlogContext, useBlogPosts, usePost };
1487
+ export { AuthorAvatar, BLOG_POSTS_LOADER, BLOG_POST_EVENT_FIELDS, BLOG_POST_LOADER, BlogList, BlogProvider, PostCard, PostDetail, articleJsonLd, blogJsonLd, blogListSeo, blogUrl, buildBlogDomainEvents, createBlogPlugin, createMockBlogProvider, createSupabaseBlogProvider, getBlogProvider, hasBlogProvider, postSeo, postUrl, registerBlogLoaders, resetBlogProvider, setBlogProvider, useBlogContext, useBlogPosts, usePost };
521
1488
  //# sourceMappingURL=index.js.map
522
1489
  //# sourceMappingURL=index.js.map