feat(blog): data-driven blog API + DB for content-pipeline publishing
Makes the blog publishable from the Süper Panel content pipeline (via n8n).
Backend mirrors the changelog module; frontend keeps the existing hand-authored
posts and merges in API-backed ones (no content migration, no regression).
API (apps/api):
- blog_posts Drizzle table (slug unique, title, meta_description, body_markdown,
tags, cover_image, status, source, published_at)
- blog module: GET /blog/posts (public list), GET /blog/posts/:slug (public),
POST /blog/posts/internal (Bearer BLOG_AUTOMATION_TOKEN, mirrors
changelog/internal) — returns { ...post, url }
- BlogService: Drizzle + Redis cache, defensive Turkish-aware slugify +
uniqueness; registered in app.module
Web (apps/web):
- use-blog hooks (list + by-slug, react-query, 30m staleTime)
- blog list: static + API merged, dedup by slug, newest first
- blog detail: static post renders as before; API post renders body_markdown
via react-markdown (remark-gfm) styled to match existing prose
- add react-markdown + remark-gfm
Deploy: set BLOG_AUTOMATION_TOKEN (and optional PUBLIC_WEB_URL) on the api,
run drizzle-kit db:push to create blog_posts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -500,6 +500,30 @@ export const changelogEntries = pgTable(
|
||||
(table) => [index("changelog_entries_published_at_idx").on(table.publishedAt)],
|
||||
);
|
||||
|
||||
// ─── Blog posts (auto-published from Süper Panel content pipeline) ──────
|
||||
export const blogPosts = pgTable(
|
||||
"blog_posts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
slug: varchar("slug", { length: 180 }).notNull(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
metaDescription: varchar("meta_description", { length: 320 }),
|
||||
bodyMarkdown: text("body_markdown").notNull(),
|
||||
tags: jsonb("tags").$type<string[]>().default([]).notNull(),
|
||||
coverImage: varchar("cover_image", { length: 500 }),
|
||||
status: varchar("status", { length: 20 }).default("published").notNull(),
|
||||
source: varchar("source", { length: 20 }).default("panel").notNull(),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("blog_posts_slug_idx").on(table.slug),
|
||||
index("blog_posts_published_at_idx").on(table.publishedAt),
|
||||
index("blog_posts_status_idx").on(table.status),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── EMEX Category Translations ─────────────────────
|
||||
export const emexCategoryTranslations = pgTable(
|
||||
"emex_category_translations",
|
||||
|
||||
Reference in New Issue
Block a user