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

View File

@@ -31,6 +31,8 @@
"posthog-js": "^1.347.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.1.0",
"remark-gfm": "^4.0.1",
"remotion": "^4.0.422",
"sileo": "^0.0.7",
"tailwind-merge": "^2.6.0",

View File

@@ -0,0 +1,40 @@
import { api } from "@/lib/api-client";
import { useQuery } from "@tanstack/react-query";
export interface BlogPostListItem {
slug: string;
title: string;
metaDescription: string | null;
tags: string[];
coverImage: string | null;
publishedAt: string;
}
export interface BlogPost extends BlogPostListItem {
id: string;
bodyMarkdown: string;
status: string;
source: string;
createdAt: string;
updatedAt: string;
}
const STALE = 30 * 60 * 1000; // 30 min — matches the API Redis cache TTL
export function useBlogPosts() {
return useQuery({
queryKey: ["blog", "list"],
queryFn: () => api.get<BlogPostListItem[]>("/blog/posts"),
staleTime: STALE,
});
}
export function useBlogPost(slug: string, enabled = true) {
return useQuery({
queryKey: ["blog", "post", slug],
queryFn: () => api.get<BlogPost>(`/blog/posts/${slug}`),
staleTime: STALE,
enabled: enabled && Boolean(slug),
retry: false,
});
}

View File

@@ -1,4 +1,5 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { useBlogPosts } from "@/hooks/use-blog";
import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
@@ -7,7 +8,11 @@ export const Route = createFileRoute("/blog")({
component: BlogPage,
});
const posts = [
type PostCard = { title: string; description: string; date: string; slug: string };
// Original hand-authored posts. Kept so they never disappear; auto-published
// posts from the content pipeline are merged in from the API below.
const staticPosts: PostCard[] = [
{
title: "Şase Numarası (VIN) Nedir? Nasıl Okunur?",
description:
@@ -45,6 +50,22 @@ function BlogPage() {
canonical: "https://sase.tr/blog",
});
const { data: apiPosts } = useBlogPosts();
// Merge static + API posts, dedupe by slug (static wins), newest first.
const bySlug = new Map<string, PostCard>();
for (const p of staticPosts) bySlug.set(p.slug, p);
for (const p of apiPosts ?? []) {
if (bySlug.has(p.slug)) continue;
bySlug.set(p.slug, {
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
});
}
const posts = [...bySlug.values()].sort((a, b) => b.date.localeCompare(a.date));
return (
<div className="min-h-screen">
<header className="border-b">

View File

@@ -1,7 +1,10 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { useBlogPost } from "@/hooks/use-blog";
import { Button } from "@sase/ui";
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// ─── BLOG POST DATA ───────────────────────────────────────────────────────────
@@ -341,27 +344,72 @@ const POSTS: Record<string, BlogPost> = {
},
};
// ─── MARKDOWN RENDERING (API-backed posts) ─────────────────────────────────────
// Maps markdown to the same prose styling the hand-authored posts use.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => (
<ul className="list-disc space-y-2 pl-6" {...props} />
),
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => (
<a className="text-foreground underline" {...props} />
),
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
// ─── ROUTE ───────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/blog_/$slug")({
component: BlogPostPage,
beforeLoad: ({ params }) => {
if (!POSTS[params.slug]) {
throw notFound();
}
},
});
function BlogPostPage() {
const { slug } = Route.useParams();
const post = POSTS[slug];
const staticPost = POSTS[slug];
const { data: apiPost, isLoading, isError } = useBlogPost(slug, !staticPost);
const title = staticPost?.title ?? apiPost?.title ?? "Blog";
const description = staticPost?.description ?? apiPost?.metaDescription ?? "";
const date = staticPost?.date ?? apiPost?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${post.title} | Sase.tr Blog`,
description: post.description,
canonical: `https://sase.tr/blog/${post.slug}`,
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (staticPost) body = staticPost.content;
else if (apiPost) body = <MarkdownBody markdown={apiPost.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">
{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}
</p>
);
return (
<div className="min-h-screen">
<header className="border-b">
@@ -387,15 +435,15 @@ function BlogPostPage() {
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{post.title}</span>
<span className="text-foreground">{title}</span>
</nav>
<article>
<time className="text-sm text-muted-foreground">{post.date}</time>
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{post.title}</h1>
<p className="mt-4 text-lg text-muted-foreground">{post.description}</p>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
<div className="mt-10">{post.content}</div>
<div className="mt-10">{body}</div>
</article>
{/* Back link */}