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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user