@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.
- package/README.md +44 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +5 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +170 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +171 -12
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +6 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/types.d.ts +13 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +5 -0
- package/src/data/supabase.ts +84 -17
- package/src/index.ts +11 -2
- package/src/migrations/001_blog.sql +103 -0
- package/src/migrations/index.ts +112 -0
- package/src/types.ts +14 -1
package/src/data/supabase.ts
CHANGED
|
@@ -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
|
|
6
|
+
// Supabase-backed blog provider (public/website read side).
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|