@fayz-ai/plugin-blog 0.1.0 → 0.8.1

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.
@@ -1,28 +1,95 @@
1
+ import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
1
2
  import type { BlogDataProvider } from './types'
2
- import type { BlogPost, BlogListQuery } from '../types'
3
+ import type { BlogPost, BlogListQuery, BlogAuthor } from '../types'
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
- // Supabase-backed blog provider — STUB.
6
+ // Supabase-backed blog provider (public/website read side).
6
7
  //
7
- // Deferred (Phase 2): read from a `blog_posts` table (tenant-scoped, RLS) with
8
- // columns mirroring BlogPost (slug, title, excerpt, body, tag, read_time, date,
9
- // image, author). Wiring this up is a pure provider swap — no hook/component
10
- // change — because everything downstream depends only on BlogDataProvider.
8
+ // Reads PUBLISHED posts from the anon-safe view public.v_public_blog_posts
9
+ // (see src/migrations/001_blog.sql §4), scoped to a tenant. The backoffice
10
+ // writes to plg_blog_posts directly via the generic CRUD provider; here we only
11
+ // read the published, column-whitelisted view — safe under the publishable key.
11
12
  //
12
- // Until then this throws, which makes createSafeDataProvider fall back to the
13
- // mock provider whenever no Supabase client is configured. If a client IS
14
- // configured but this is still a stub, surface a clear error.
13
+ // The tenant is taken from the configured tenantId (anon site) or, failing that,
14
+ // the active session tenant. Without a client or a tenant we return nothing, and
15
+ // createSafeDataProvider falls back to the mock/seed provider.
15
16
  // ---------------------------------------------------------------------------
16
17
 
17
- export function createSupabaseBlogProvider(): BlogDataProvider {
18
- const notImplemented = (): never => {
19
- throw new Error(
20
- '[plugin-blog] Supabase provider not implemented yet — deferred to Phase 2. ' +
21
- 'Run on the mock/seed provider (no Supabase client configured) for now.',
22
- )
18
+ const PUBLIC_VIEW = 'v_public_blog_posts'
19
+
20
+ export interface SupabaseBlogProviderOptions {
21
+ /** Tenant whose published posts to read (anon website). Defaults to the active session tenant. */
22
+ tenantId?: string
23
+ }
24
+
25
+ function getClient() {
26
+ const supabase = getSupabaseClientOptional() as {
27
+ from: (t: string) => any
28
+ } | null
29
+ if (!supabase) throw new Error('[plugin-blog] Supabase client not initialized')
30
+ return supabase
31
+ }
32
+
33
+ /** Human month-year label (pt-BR) derived from an ISO date, e.g. "Janeiro 2025". */
34
+ function humanDate(iso?: string | null): string {
35
+ if (!iso) return ''
36
+ const d = new Date(iso)
37
+ if (Number.isNaN(d.getTime())) return ''
38
+ const label = d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
39
+ return label.charAt(0).toUpperCase() + label.slice(1)
40
+ }
41
+
42
+ function toPost(row: Record<string, any>): BlogPost {
43
+ const author: BlogAuthor = {
44
+ name: row.author_name ?? 'Equipe',
45
+ avatarUrl: row.author_avatar_url ?? undefined,
46
+ role: row.author_role ?? undefined,
47
+ bio: row.author_bio ?? undefined,
23
48
  }
24
49
  return {
25
- listPosts: (_query?: BlogListQuery): Promise<BlogPost[]> => notImplemented(),
26
- getPost: (_slug: string): Promise<BlogPost | null> => notImplemented(),
50
+ slug: row.slug,
51
+ title: row.title,
52
+ excerpt: row.excerpt ?? '',
53
+ body: row.body ?? '',
54
+ tag: row.tag ?? '',
55
+ readTime: row.read_time ?? '',
56
+ date: humanDate(row.published_at),
57
+ publishedAt: row.published_at ?? undefined,
58
+ image: row.cover_image ?? '',
59
+ author,
60
+ }
61
+ }
62
+
63
+ export function createSupabaseBlogProvider(options?: SupabaseBlogProviderOptions): BlogDataProvider {
64
+ const resolveTenantId = (): string | undefined => options?.tenantId ?? getActiveTenantId() ?? undefined
65
+
66
+ return {
67
+ async listPosts(query?: BlogListQuery): Promise<BlogPost[]> {
68
+ const tenantId = resolveTenantId()
69
+ if (!tenantId) return []
70
+ let q = getClient()
71
+ .from(PUBLIC_VIEW)
72
+ .select('*')
73
+ .eq('tenant_id', tenantId)
74
+ .order('published_at', { ascending: false, nullsFirst: false })
75
+ if (query?.tag) q = q.eq('tag', query.tag)
76
+ if (query?.limit != null) q = q.limit(query.limit)
77
+ const { data, error } = await q
78
+ if (error) throw error
79
+ return (data ?? []).map(toPost)
80
+ },
81
+
82
+ async getPost(slug: string): Promise<BlogPost | null> {
83
+ const tenantId = resolveTenantId()
84
+ if (!tenantId) return null
85
+ const { data, error } = await getClient()
86
+ .from(PUBLIC_VIEW)
87
+ .select('*')
88
+ .eq('tenant_id', tenantId)
89
+ .eq('slug', slug)
90
+ .maybeSingle()
91
+ if (error) throw error
92
+ return data ? toPost(data) : null
93
+ },
27
94
  }
28
95
  }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { createSupabaseBlogProvider } from './data/supabase'
7
7
  import { BlogProvider, type BlogContextValue } from './context'
8
8
  import { BlogList } from './components/BlogList'
9
9
  import { PostDetail } from './components/PostDetail'
10
+ import { MIGRATIONS } from './migrations'
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // @fayz-ai/plugin-blog — website-surface blog/articles plugin.
@@ -32,7 +33,7 @@ export function createBlogPlugin(options?: BlogPluginOptions): BlogWebsitePlugin
32
33
  const provider =
33
34
  options?.dataProvider ??
34
35
  createSafeDataProvider(
35
- () => createSupabaseBlogProvider(),
36
+ () => createSupabaseBlogProvider({ tenantId: options?.tenantId }),
36
37
  () => createMockBlogProvider({ seed: options?.seed, defaultAuthor: options?.defaultAuthor }),
37
38
  )
38
39
 
@@ -59,6 +60,14 @@ export function createBlogPlugin(options?: BlogPluginOptions): BlogWebsitePlugin
59
60
  { path: `${config.basePath}/:slug`, component: PostDetail, guard: 'public' },
60
61
  ],
61
62
  widgets: [],
63
+ // Owns the blog domain tables (categories + posts + anon-read view). The
64
+ // marketing "Blog" backoffice writes to these; the website reads them.
65
+ migrations: MIGRATIONS.map((m) => ({
66
+ id: `blog-${m.id}`,
67
+ version: '1.0.0',
68
+ sql: m.sql,
69
+ description: 'Blog content model (plg_blog_categories, plg_blog_posts) + anon-read view',
70
+ })),
62
71
  }
63
72
 
64
73
  return { manifest, Provider, dataProvider: provider }
@@ -66,7 +75,7 @@ export function createBlogPlugin(options?: BlogPluginOptions): BlogWebsitePlugin
66
75
 
67
76
  // Public API
68
77
  export type { BlogPluginOptions, ResolvedBlogConfig } from './config'
69
- export type { BlogPost, BlogListQuery, BlogAuthor } from './types'
78
+ export type { BlogPost, BlogListQuery, BlogAuthor, BlogCategory } from './types'
70
79
  export type { BlogDataProvider } from './data/types'
71
80
  export { createMockBlogProvider, createSupabaseBlogProvider } from './data'
72
81
  export type { BlogSeed, BlogSeedPost } from './data/mock'
@@ -0,0 +1,103 @@
1
+ -- ============================================================================
2
+ -- plugin-blog 001: blog content model + public (anon) read surface
3
+ --
4
+ -- Backs the blog backoffice (managed from the marketing plugin's "Blog" tab) and
5
+ -- the public website surface (/blog, /blog/:slug). Prefix: plg_blog_.
6
+ -- §1 plg_blog_categories — first-class category (name/slug/description)
7
+ -- §2 plg_blog_posts — articles; category_id FK, author byline, status
8
+ -- §3 RLS: authenticated tenant CRUD on both tables (the backoffice)
9
+ -- §4 v_public_blog_posts — anon-readable view of PUBLISHED posts (the site)
10
+ --
11
+ -- Anon-access design mirrors plugin-agenda's public booking surface: the public
12
+ -- website runs with the publishable key and no session, so canonical tenant RLS
13
+ -- yields nothing. Anon read is granted ONLY through the column-whitelisted,
14
+ -- published-only view (§4); the front provider filters it by tenant_id.
15
+ -- Idempotent + safe to re-run.
16
+ -- ============================================================================
17
+
18
+ -- §1 — categories
19
+ CREATE TABLE IF NOT EXISTS public.plg_blog_categories (
20
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
21
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
22
+ name text NOT NULL,
23
+ slug text NOT NULL,
24
+ description text,
25
+ created_at timestamptz NOT NULL DEFAULT now(),
26
+ updated_at timestamptz NOT NULL DEFAULT now()
27
+ );
28
+ ALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;
29
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_categories_tenant ON public.plg_blog_categories(tenant_id);
30
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories(tenant_id, slug);
31
+
32
+ -- §2 — posts
33
+ CREATE TABLE IF NOT EXISTS public.plg_blog_posts (
34
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
35
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
36
+ category_id uuid REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL,
37
+ slug text NOT NULL,
38
+ title text NOT NULL,
39
+ excerpt text NOT NULL DEFAULT '',
40
+ body text NOT NULL DEFAULT '',
41
+ cover_image text,
42
+ author_name text,
43
+ author_role text,
44
+ author_avatar_url text,
45
+ author_bio text,
46
+ status text NOT NULL DEFAULT 'draft'
47
+ CHECK (status IN ('draft', 'published')),
48
+ read_time text,
49
+ published_at timestamptz,
50
+ created_at timestamptz NOT NULL DEFAULT now(),
51
+ updated_at timestamptz NOT NULL DEFAULT now()
52
+ );
53
+ ALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;
54
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_tenant ON public.plg_blog_posts(tenant_id);
55
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_category ON public.plg_blog_posts(category_id);
56
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_status ON public.plg_blog_posts(tenant_id, status, published_at DESC);
57
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts(tenant_id, slug);
58
+
59
+ -- §3 — RLS: authenticated tenant CRUD (the backoffice writes here)
60
+ DROP POLICY IF EXISTS plg_blog_categories_select ON public.plg_blog_categories;
61
+ DROP POLICY IF EXISTS plg_blog_categories_insert ON public.plg_blog_categories;
62
+ DROP POLICY IF EXISTS plg_blog_categories_update ON public.plg_blog_categories;
63
+ DROP POLICY IF EXISTS plg_blog_categories_delete ON public.plg_blog_categories;
64
+ CREATE POLICY plg_blog_categories_select ON public.plg_blog_categories FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
65
+ 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()));
66
+ CREATE POLICY plg_blog_categories_update ON public.plg_blog_categories FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
67
+ CREATE POLICY plg_blog_categories_delete ON public.plg_blog_categories FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
68
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_categories TO authenticated;
69
+
70
+ DROP POLICY IF EXISTS plg_blog_posts_select ON public.plg_blog_posts;
71
+ DROP POLICY IF EXISTS plg_blog_posts_insert ON public.plg_blog_posts;
72
+ DROP POLICY IF EXISTS plg_blog_posts_update ON public.plg_blog_posts;
73
+ DROP POLICY IF EXISTS plg_blog_posts_delete ON public.plg_blog_posts;
74
+ CREATE POLICY plg_blog_posts_select ON public.plg_blog_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
75
+ 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()));
76
+ CREATE POLICY plg_blog_posts_update ON public.plg_blog_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
77
+ CREATE POLICY plg_blog_posts_delete ON public.plg_blog_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
78
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_posts TO authenticated;
79
+
80
+ -- §4 — anon-readable public catalog (owner-rights view = deliberate, whitelisted,
81
+ -- PUBLISHED only). The front provider filters by tenant_id. Category name is
82
+ -- surfaced as `tag` so the website's BlogPost shape stays unchanged.
83
+ CREATE OR REPLACE VIEW public.v_public_blog_posts AS
84
+ SELECT
85
+ p.id,
86
+ p.tenant_id,
87
+ p.slug,
88
+ p.title,
89
+ p.excerpt,
90
+ p.body,
91
+ COALESCE(c.name, '') AS tag,
92
+ p.cover_image,
93
+ p.author_name,
94
+ p.author_role,
95
+ p.author_avatar_url,
96
+ p.author_bio,
97
+ p.read_time,
98
+ p.published_at
99
+ FROM public.plg_blog_posts p
100
+ LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
101
+ WHERE p.status = 'published';
102
+
103
+ GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
@@ -0,0 +1,112 @@
1
+ // AUTO-GENERATED from 001_blog.sql — regenerate with scripts/embed-migrations.mjs
2
+ // SQL files are the source of truth; this inline copy lets the manifest declare
3
+ // migrations as data. Do not edit by hand — run the embed script instead.
4
+
5
+ export const MIGRATION_001_BLOG = `-- ============================================================================
6
+ -- plugin-blog 001: blog content model + public (anon) read surface
7
+ --
8
+ -- Backs the blog backoffice (managed from the marketing plugin's "Blog" tab) and
9
+ -- the public website surface (/blog, /blog/:slug). Prefix: plg_blog_.
10
+ -- §1 plg_blog_categories — first-class category (name/slug/description)
11
+ -- §2 plg_blog_posts — articles; category_id FK, author byline, status
12
+ -- §3 RLS: authenticated tenant CRUD on both tables (the backoffice)
13
+ -- §4 v_public_blog_posts — anon-readable view of PUBLISHED posts (the site)
14
+ --
15
+ -- Anon-access design mirrors plugin-agenda's public booking surface: the public
16
+ -- website runs with the publishable key and no session, so canonical tenant RLS
17
+ -- yields nothing. Anon read is granted ONLY through the column-whitelisted,
18
+ -- published-only view (§4); the front provider filters it by tenant_id.
19
+ -- Idempotent + safe to re-run.
20
+ -- ============================================================================
21
+
22
+ -- §1 — categories
23
+ CREATE TABLE IF NOT EXISTS public.plg_blog_categories (
24
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
25
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
26
+ name text NOT NULL,
27
+ slug text NOT NULL,
28
+ description text,
29
+ created_at timestamptz NOT NULL DEFAULT now(),
30
+ updated_at timestamptz NOT NULL DEFAULT now()
31
+ );
32
+ ALTER TABLE public.plg_blog_categories ENABLE ROW LEVEL SECURITY;
33
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_categories_tenant ON public.plg_blog_categories(tenant_id);
34
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_categories_tenant_slug ON public.plg_blog_categories(tenant_id, slug);
35
+
36
+ -- §2 — posts
37
+ CREATE TABLE IF NOT EXISTS public.plg_blog_posts (
38
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
39
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
40
+ category_id uuid REFERENCES public.plg_blog_categories(id) ON DELETE SET NULL,
41
+ slug text NOT NULL,
42
+ title text NOT NULL,
43
+ excerpt text NOT NULL DEFAULT '',
44
+ body text NOT NULL DEFAULT '',
45
+ cover_image text,
46
+ author_name text,
47
+ author_role text,
48
+ author_avatar_url text,
49
+ author_bio text,
50
+ status text NOT NULL DEFAULT 'draft'
51
+ CHECK (status IN ('draft', 'published')),
52
+ read_time text,
53
+ published_at timestamptz,
54
+ created_at timestamptz NOT NULL DEFAULT now(),
55
+ updated_at timestamptz NOT NULL DEFAULT now()
56
+ );
57
+ ALTER TABLE public.plg_blog_posts ENABLE ROW LEVEL SECURITY;
58
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_tenant ON public.plg_blog_posts(tenant_id);
59
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_category ON public.plg_blog_posts(category_id);
60
+ CREATE INDEX IF NOT EXISTS idx_plg_blog_posts_status ON public.plg_blog_posts(tenant_id, status, published_at DESC);
61
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_blog_posts_tenant_slug ON public.plg_blog_posts(tenant_id, slug);
62
+
63
+ -- §3 — RLS: authenticated tenant CRUD (the backoffice writes here)
64
+ DROP POLICY IF EXISTS plg_blog_categories_select ON public.plg_blog_categories;
65
+ DROP POLICY IF EXISTS plg_blog_categories_insert ON public.plg_blog_categories;
66
+ DROP POLICY IF EXISTS plg_blog_categories_update ON public.plg_blog_categories;
67
+ DROP POLICY IF EXISTS plg_blog_categories_delete ON public.plg_blog_categories;
68
+ CREATE POLICY plg_blog_categories_select ON public.plg_blog_categories FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
69
+ 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()));
70
+ CREATE POLICY plg_blog_categories_update ON public.plg_blog_categories FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
71
+ CREATE POLICY plg_blog_categories_delete ON public.plg_blog_categories FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
72
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_categories TO authenticated;
73
+
74
+ DROP POLICY IF EXISTS plg_blog_posts_select ON public.plg_blog_posts;
75
+ DROP POLICY IF EXISTS plg_blog_posts_insert ON public.plg_blog_posts;
76
+ DROP POLICY IF EXISTS plg_blog_posts_update ON public.plg_blog_posts;
77
+ DROP POLICY IF EXISTS plg_blog_posts_delete ON public.plg_blog_posts;
78
+ CREATE POLICY plg_blog_posts_select ON public.plg_blog_posts FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
79
+ 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()));
80
+ CREATE POLICY plg_blog_posts_update ON public.plg_blog_posts FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
81
+ CREATE POLICY plg_blog_posts_delete ON public.plg_blog_posts FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
82
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_blog_posts TO authenticated;
83
+
84
+ -- §4 — anon-readable public catalog (owner-rights view = deliberate, whitelisted,
85
+ -- PUBLISHED only). The front provider filters by tenant_id. Category name is
86
+ -- surfaced as \`tag\` so the website's BlogPost shape stays unchanged.
87
+ CREATE OR REPLACE VIEW public.v_public_blog_posts AS
88
+ SELECT
89
+ p.id,
90
+ p.tenant_id,
91
+ p.slug,
92
+ p.title,
93
+ p.excerpt,
94
+ p.body,
95
+ COALESCE(c.name, '') AS tag,
96
+ p.cover_image,
97
+ p.author_name,
98
+ p.author_role,
99
+ p.author_avatar_url,
100
+ p.author_bio,
101
+ p.read_time,
102
+ p.published_at
103
+ FROM public.plg_blog_posts p
104
+ LEFT JOIN public.plg_blog_categories c ON c.id = p.category_id
105
+ WHERE p.status = 'published';
106
+
107
+ GRANT SELECT ON public.v_public_blog_posts TO anon, authenticated, service_role;
108
+ `
109
+
110
+ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
111
+ { id: "001_blog", sql: MIGRATION_001_BLOG },
112
+ ]
package/src/types.ts CHANGED
@@ -15,6 +15,19 @@ export interface BlogAuthor {
15
15
  bio?: string
16
16
  }
17
17
 
18
+ /**
19
+ * First-class blog category. Managed in the backoffice (marketing "Blog" tab);
20
+ * a post references one via categoryId. The website's BlogPost surfaces the
21
+ * category's `name` as its `tag` string so presentation stays unchanged.
22
+ */
23
+ export interface BlogCategory {
24
+ id: string
25
+ name: string
26
+ /** URL-safe identifier (unique per tenant). */
27
+ slug: string
28
+ description?: string
29
+ }
30
+
18
31
  export interface BlogPost {
19
32
  /** URL-safe identifier used for the /blog/:slug detail route. */
20
33
  slug: string
@@ -23,7 +36,7 @@ export interface BlogPost {
23
36
  excerpt: string
24
37
  /** Full article body. Plain text or lightweight markdown (paragraphs split on blank lines). */
25
38
  body: string
26
- /** Category / topic label (e.g. "Periodontia"). */
39
+ /** Category / topic label (e.g. "Periodontia"). Derived from the post's category name. */
27
40
  tag: string
28
41
  /** Human-readable reading time (e.g. "5 min"). */
29
42
  readTime: string