Files
iti-technology/apps/api/src/routes/public/blog.ts
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

145 lines
5.4 KiB
TypeScript

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<number>`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