Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
Deploy stack su Proxmox / deploy (push) Failing after 3s
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
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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
|
||||
Reference in New Issue
Block a user