feat(blog): back blog module with central Directus CMS

Blog posts now live in the shared Directus instance (Coolify / Süper Panel
project) instead of the per-env blog_posts table, so prod and staging serve
identical content. API response shape is unchanged; Redis list cache and the
n8n automation endpoint keep working as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:13:03 +03:00
parent 24e44a49f8
commit aeb438e442
2 changed files with 137 additions and 56 deletions

View File

@@ -1,7 +1,9 @@
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 {
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { RedisService } from "../redis/redis.service";
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
@@ -10,6 +12,28 @@ const CACHE_TTL = 1800; // 30 minutes — matches the web staleTime
const PUBLIC_WEB_URL = process.env.PUBLIC_WEB_URL ?? "https://sase.tr";
// Central Directus CMS (Coolify, Süper Panel project). Prod and staging share
// the same instance so blog content is identical across environments.
const DIRECTUS_URL = process.env.DIRECTUS_URL?.replace(/\/+$/, "");
const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN;
const DIRECTUS_PROJECT = process.env.DIRECTUS_PROJECT ?? "sase";
// Row shape of the shared `posts` collection in Directus.
interface DirectusPost {
id: string;
slug: string;
title: string;
meta_description: string | null;
body_markdown: string;
tags: string[] | null;
cover_image: string | null;
status: string;
source: string;
published_at: string;
date_created: string | null;
date_updated: string | null;
}
// 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 {
@@ -31,18 +55,48 @@ function slugify(input: string): string {
@Injectable()
export class BlogService {
constructor(
@Inject(DATABASE) private db: Database,
private readonly redis: RedisService,
) {}
private readonly logger = new Logger(BlogService.name);
private toPost(row: typeof blogPosts.$inferSelect): BlogPost {
constructor(private readonly redis: RedisService) {}
private async directus<T>(
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
if (!DIRECTUS_URL || !DIRECTUS_TOKEN) {
throw new ServiceUnavailableException("Blog CMS is not configured");
}
const res = await fetch(`${DIRECTUS_URL}${path}`, {
method: init.method ?? "GET",
headers: {
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
"Content-Type": "application/json",
},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`Directus ${init.method ?? "GET"} ${path} -> ${res.status}: ${detail.slice(0, 300)}`);
}
const json = (await res.json()) as { data: T };
return json.data;
}
private toPost(row: DirectusPost): BlogPost {
return {
...row,
id: row.id,
slug: row.slug,
title: row.title,
metaDescription: row.meta_description,
bodyMarkdown: row.body_markdown,
tags: row.tags ?? [],
coverImage: row.cover_image,
status: row.status as BlogPost["status"],
publishedAt: row.publishedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
source: row.source,
publishedAt: row.published_at,
createdAt: row.date_created ?? row.published_at,
updatedAt: row.date_updated ?? row.date_created ?? row.published_at,
};
}
@@ -50,55 +104,72 @@ export class BlogService {
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));
let rows: DirectusPost[];
try {
rows = await this.directus<DirectusPost[]>(
"/items/posts?" +
new URLSearchParams({
"filter[project][_eq]": DIRECTUS_PROJECT,
"filter[status][_eq]": "published",
sort: "-published_at",
fields: "slug,title,meta_description,tags,cover_image,published_at",
limit: "-1",
}),
);
} catch (err) {
// Degrade gracefully — the blog page still renders its static posts.
this.logger.error(`Blog list fetch failed: ${(err as Error).message}`);
return [];
}
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(),
}));
const items: BlogPostListItem[] = rows.map((r) => ({
slug: r.slug,
title: r.title,
metaDescription: r.meta_description,
tags: r.tags ?? [],
coverImage: r.cover_image,
publishedAt: r.published_at,
}));
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") {
let rows: DirectusPost[];
try {
rows = await this.directus<DirectusPost[]>(
"/items/posts?" +
new URLSearchParams({
"filter[project][_eq]": DIRECTUS_PROJECT,
"filter[slug][_eq]": slug,
"filter[status][_eq]": "published",
limit: "1",
}),
);
} catch (err) {
this.logger.error(`Blog post fetch failed (${slug}): ${(err as Error).message}`);
throw new NotFoundException("Blog post not found");
}
return this.toPost(result[0]);
if (rows.length === 0) {
throw new NotFoundException("Blog post not found");
}
return this.toPost(rows[0]);
}
// Ensures a unique slug by appending a numeric suffix on collision.
// Ensures a unique slug (per project) 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);
const existing = await this.directus<Pick<DirectusPost, "id">[]>(
"/items/posts?" +
new URLSearchParams({
"filter[project][_eq]": DIRECTUS_PROJECT,
"filter[slug][_eq]": slug,
fields: "id",
limit: "1",
}),
);
if (existing.length === 0) return slug;
slug = `${base}-${i}`;
}
@@ -112,19 +183,21 @@ export class BlogService {
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({
const row = await this.directus<DirectusPost>("/items/posts", {
method: "POST",
body: {
project: DIRECTUS_PROJECT,
slug,
title: dto.title,
metaDescription: dto.meta_description ?? null,
bodyMarkdown: body,
meta_description: dto.meta_description ?? null,
body_markdown: body,
tags: Array.isArray(dto.tags) ? dto.tags.slice(0, 16) : [],
coverImage: dto.coverImage ?? null,
cover_image: dto.coverImage ?? null,
status: dto.status ?? "published",
source: "panel",
})
.returning();
published_at: new Date().toISOString(),
},
});
await this.redis.del(LIST_CACHE_KEY);