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
145 lines
5.4 KiB
TypeScript
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
|