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:
2026-05-24 01:48:10 +03:00
parent 93d3b08992
commit e5c852b537
11 changed files with 1275 additions and 88 deletions

View File

@@ -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",