import { and, asc, desc, eq, isNotNull, lte, sql } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod" import { z } from "zod" import { blogCategories, blogPosts } from "../../db/schema/index.js" import { AppError } from "../../errors.js" import { toCategoryDto, toDetailDto, toSummaryDto } from "../../services/blog.js" export const categoryDto = z.object({ id: z.uuid(), slug: z.string(), name: z.string(), description: z.string().nullable(), }) export const postSummaryDto = z.object({ id: z.uuid(), slug: z.string(), title: z.string(), excerpt: z.string().nullable(), coverUrl: z.string().nullable(), category: categoryDto.nullable(), publishedAt: z.string().nullable(), }) export const postDetailDto = postSummaryDto.extend({ contentMd: z.string(), contentHtml: z.string(), seoTitle: z.string().nullable(), seoDescription: z.string().nullable(), updatedAt: z.string(), }) /** Post visibili al pubblico: pubblicati e con data di pubblicazione già passata (le future sono programmate). */ export const isPublic = and( eq(blogPosts.status, "published"), isNotNull(blogPosts.publishedAt), lte(blogPosts.publishedAt, sql`now()`), ) /** Condizione full-text: stemming italiano + unaccent (stessa configurazione della colonna generata). */ export const tsQuery = (q: string) => sql`websearch_to_tsquery('italian', unaccent(${q}))` const blogRoutes: FastifyPluginAsyncZod = async (app) => { const coverUrl = (path: string) => app.storage.publicUrl(path) // GET /blog/posts — post pubblicati; ?q= ricerca full-text ordinata per rilevanza app.get( "/blog/posts", { schema: { querystring: z.object({ category: z.string().trim().min(1).max(100).optional(), q: z.string().trim().min(1).max(200).optional(), limit: z.coerce.number().int().min(1).max(50).default(10), offset: z.coerce.number().int().min(0).max(10_000).default(0), }), response: { 200: z.object({ items: z.array(postSummaryDto), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }), }, }, }, async (request) => { const { category, q, limit, offset } = request.query const query = q ? tsQuery(q) : undefined const where = and( isPublic, category ? eq(blogCategories.slug, category) : undefined, query ? sql`${blogPosts.searchVector} @@ ${query}` : undefined, ) const order = query ? [desc(sql`ts_rank(${blogPosts.searchVector}, ${query})`), desc(blogPosts.publishedAt)] : [desc(blogPosts.publishedAt), asc(blogPosts.id)] const [rows, [count]] = await Promise.all([ app.db .select({ post: blogPosts, category: blogCategories }) .from(blogPosts) .leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id)) .where(where) .orderBy(...order) .limit(limit) .offset(offset), app.db .select({ n: sql`count(*)::int` }) .from(blogPosts) .leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id)) .where(where), ]) return { items: rows.map((r) => toSummaryDto(r.post, r.category, coverUrl)), total: count?.n ?? 0, limit, offset } }, ) // GET /blog/posts/:slug — dettaglio (markdown + HTML sanitizzato); bozze e programmati → 404 app.get( "/blog/posts/:slug", { schema: { params: z.object({ slug: z.string().min(1).max(100) }), response: { 200: postDetailDto } } }, async (request) => { const [row] = await app.db .select({ post: blogPosts, category: blogCategories }) .from(blogPosts) .leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id)) .where(and(isPublic, eq(blogPosts.slug, request.params.slug))) if (!row) throw new AppError(404, "POST_NOT_FOUND", "Articolo non trovato") return toDetailDto(row.post, row.category, coverUrl) }, ) app.get("/blog/categories", { schema: { response: { 200: z.array(categoryDto) } } }, async () => { const rows = await app.db.select().from(blogCategories).orderBy(asc(blogCategories.name)) return rows.map(toCategoryDto) }) // GET /sitemap-data — slug e date per la sitemap generata dal frontend SSR app.get( "/sitemap-data", { schema: { response: { 200: z.object({ posts: z.array(z.object({ slug: z.string(), publishedAt: z.string(), updatedAt: z.string() })), categories: z.array(z.object({ slug: z.string(), updatedAt: z.string() })), }), }, }, }, async (_request, reply) => { const [posts, categories] = await Promise.all([ app.db .select({ slug: blogPosts.slug, publishedAt: blogPosts.publishedAt, updatedAt: blogPosts.updatedAt }) .from(blogPosts) .where(isPublic) .orderBy(desc(blogPosts.publishedAt)), app.db.select({ slug: blogCategories.slug, updatedAt: blogCategories.updatedAt }).from(blogCategories), ]) reply.header("cache-control", "public, max-age=300") return { posts: posts.map((p) => ({ slug: p.slug, publishedAt: p.publishedAt!.toISOString(), updatedAt: p.updatedAt.toISOString() })), categories: categories.map((c) => ({ slug: c.slug, updatedAt: c.updatedAt.toISOString() })), } }, ) } export default blogRoutes