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:
@@ -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,
|
||||
|
||||
59
apps/api/src/blog/blog.controller.ts
Normal file
59
apps/api/src/blog/blog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
apps/api/src/blog/blog.dto.ts
Normal file
38
apps/api/src/blog/blog.dto.ts
Normal 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;
|
||||
}
|
||||
9
apps/api/src/blog/blog.module.ts
Normal file
9
apps/api/src/blog/blog.module.ts
Normal 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 {}
|
||||
133
apps/api/src/blog/blog.service.ts
Normal file
133
apps/api/src/blog/blog.service.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
apps/web/src/hooks/use-blog.ts
Normal file
40
apps/web/src/hooks/use-blog.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user