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

@@ -12,6 +12,7 @@ import { BrandsModule } from "./brands/brands.module";
import { CatalogModule } from "./catalog/catalog.module";
import { CategoriesModule } from "./categories/categories.module";
import { ChangelogModule } from "./changelog/changelog.module";
import { BlogModule } from "./blog/blog.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { AuthGuard } from "./common/guards/auth.guard";
import { ImpersonationReadonlyGuard } from "./common/guards/impersonation-readonly.guard";
@@ -85,6 +86,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
AnalyticsModule,
CatalogModule,
ChangelogModule,
BlogModule,
PostHogModule,
TelemetryModule,
InternalAdminModule,

View File

@@ -0,0 +1,59 @@
import { timingSafeEqual } from "node:crypto";
import {
Body,
Controller,
Get,
Headers,
Param,
Post,
ServiceUnavailableException,
UnauthorizedException,
} from "@nestjs/common";
import { Public } from "../common/decorators/public.decorator";
import type { CreateBlogPost } from "./blog.dto";
import { BlogService } from "./blog.service";
@Controller("blog")
export class BlogController {
constructor(private readonly blogService: BlogService) {}
@Get("posts")
@Public()
async list() {
return this.blogService.findAllPublished();
}
@Get("posts/:slug")
@Public()
async bySlug(@Param("slug") slug: string) {
return this.blogService.findBySlug(slug);
}
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
// Mirrors the changelog/internal Bearer-token pattern.
@Post("posts/internal")
@Public()
async createInternal(
@Headers("authorization") authHeader: string,
@Body() body: CreateBlogPost,
) {
const token = process.env.BLOG_AUTOMATION_TOKEN;
if (!token) {
throw new ServiceUnavailableException("Blog automation endpoint is not configured");
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing or invalid Authorization header");
}
const provided = authHeader.slice(7);
if (
provided.length !== token.length ||
!timingSafeEqual(Buffer.from(provided), Buffer.from(token))
) {
throw new UnauthorizedException("Invalid token");
}
if (!body?.title || !body?.body_markdown) {
throw new UnauthorizedException("title and body_markdown are required");
}
return this.blogService.create(body);
}
}

View File

@@ -0,0 +1,38 @@
export type BlogPostStatus = "draft" | "published";
export interface BlogPost {
id: string;
slug: string;
title: string;
metaDescription: string | null;
bodyMarkdown: string;
tags: string[];
coverImage: string | null;
status: BlogPostStatus;
source: string;
publishedAt: string;
createdAt: string;
updatedAt: string;
}
export interface BlogPostListItem {
slug: string;
title: string;
metaDescription: string | null;
tags: string[];
coverImage: string | null;
publishedAt: string;
}
// Wire shape from the Süper Panel content pipeline (via n8n). Field names match
// the content_blog generation schema (snake_case), mapped to columns in the service.
export interface CreateBlogPost {
title: string;
slug?: string;
meta_description?: string;
body_markdown: string;
tags?: string[];
cta?: string;
coverImage?: string;
status?: BlogPostStatus;
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { BlogController } from "./blog.controller";
import { BlogService } from "./blog.service";
@Module({
controllers: [BlogController],
providers: [BlogService],
})
export class BlogModule {}

View File

@@ -0,0 +1,133 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { blogPosts } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
const LIST_CACHE_KEY = "blog:posts:list";
const CACHE_TTL = 1800; // 30 minutes — matches the web staleTime
const PUBLIC_WEB_URL = process.env.PUBLIC_WEB_URL ?? "https://sase.tr";
// URL-safe slug, Turkish-aware. The content_blog prompt already emits a clean
// slug, but we slugify defensively (and to derive one if it's missing).
function slugify(input: string): string {
const map: Record<string, string> = {
ç: "c", ğ: "g", ı: "i", İ: "i", ö: "o", ş: "s", ü: "u",
Ç: "c", Ğ: "g", Ö: "o", Ş: "s", Ü: "u",
};
return input
.split("")
.map((c) => map[c] ?? c)
.join("")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 160) || "yazi";
}
@Injectable()
export class BlogService {
constructor(
@Inject(DATABASE) private db: Database,
private readonly redis: RedisService,
) {}
private toPost(row: typeof blogPosts.$inferSelect): BlogPost {
return {
...row,
status: row.status as BlogPost["status"],
publishedAt: row.publishedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async findAllPublished(): Promise<BlogPostListItem[]> {
const cached = await this.redis.getJson<BlogPostListItem[]>(LIST_CACHE_KEY);
if (cached) return cached;
const rows = await this.db
.select({
slug: blogPosts.slug,
title: blogPosts.title,
metaDescription: blogPosts.metaDescription,
tags: blogPosts.tags,
coverImage: blogPosts.coverImage,
publishedAt: blogPosts.publishedAt,
status: blogPosts.status,
})
.from(blogPosts)
.orderBy(desc(blogPosts.publishedAt));
const items: BlogPostListItem[] = rows
.filter((r) => r.status === "published")
.map((r) => ({
slug: r.slug,
title: r.title,
metaDescription: r.metaDescription,
tags: r.tags,
coverImage: r.coverImage,
publishedAt: r.publishedAt.toISOString(),
}));
await this.redis.setJson(LIST_CACHE_KEY, items, CACHE_TTL);
return items;
}
async findBySlug(slug: string): Promise<BlogPost> {
const result = await this.db
.select()
.from(blogPosts)
.where(eq(blogPosts.slug, slug))
.limit(1);
if (result.length === 0 || result[0].status !== "published") {
throw new NotFoundException("Blog post not found");
}
return this.toPost(result[0]);
}
// Ensures a unique slug by appending a numeric suffix on collision.
private async uniqueSlug(base: string): Promise<string> {
let slug = base;
for (let i = 2; i < 50; i++) {
const existing = await this.db
.select({ id: blogPosts.id })
.from(blogPosts)
.where(eq(blogPosts.slug, slug))
.limit(1);
if (existing.length === 0) return slug;
slug = `${base}-${i}`;
}
return `${base}-${Date.now().toString(36)}`;
}
async create(dto: CreateBlogPost): Promise<BlogPost & { url: string }> {
const baseSlug = slugify(dto.slug?.trim() || dto.title);
const slug = await this.uniqueSlug(baseSlug);
let body = dto.body_markdown ?? "";
if (dto.cta && !body.includes(dto.cta)) body = `${body}\n\n${dto.cta}`;
const [row] = await this.db
.insert(blogPosts)
.values({
slug,
title: dto.title,
metaDescription: dto.meta_description ?? null,
bodyMarkdown: body,
tags: Array.isArray(dto.tags) ? dto.tags.slice(0, 16) : [],
coverImage: dto.coverImage ?? null,
status: dto.status ?? "published",
source: "panel",
})
.returning();
await this.redis.del(LIST_CACHE_KEY);
return { ...this.toPost(row), url: `${PUBLIC_WEB_URL}/blog/${slug}` };
}
}

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