feat: add revalidation functionality and integrate it into various components and API routes

This commit is contained in:
Marco Pedone 2025-10-17 19:14:27 +02:00
parent bc1f114837
commit 727f1e760c
10 changed files with 331 additions and 106 deletions

View file

@ -28,6 +28,7 @@ env:
SKIP_REDIS: "false" SKIP_REDIS: "false"
SKEBBY_USER: "test" SKEBBY_USER: "test"
SKEBBY_PASS : "test" SKEBBY_PASS : "test"
REVALIDATION_SECRET: "test"
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -63,6 +63,7 @@ ENV KEYDB_URL="localhost:6380"
ENV TILES_URL="localhost:6379" ENV TILES_URL="localhost:6379"
ENV SKEBBY_USER="mock" ENV SKEBBY_USER="mock"
ENV SKEBBY_PASS="mock" ENV SKEBBY_PASS="mock"
ENV REVALIDATION_SECRET="mock"
RUN SKIP_ENV_VALIDATION=1 npm run build RUN SKIP_ENV_VALIDATION=1 npm run build
@ -128,6 +129,8 @@ ARG SKEBBY_USER
ENV SKEBBY_USER=$SKEBBY_USER ENV SKEBBY_USER=$SKEBBY_USER
ARG SKEBBY_PASS ARG SKEBBY_PASS
ENV SKEBBY_PASS=$SKEBBY_PASS ENV SKEBBY_PASS=$SKEBBY_PASS
ENV REVALIDATION_SECRET="SWKpgaaLfsyeqV1eOT0WG7TUFBewir8kJXure3O37ki8Lt4Z4IiEgBW4zPHDdM5c"
RUN addgroup -g 1001 -S nodejs RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001 RUN adduser -S nextjs -u 1001

View file

@ -30,6 +30,7 @@ const server = z.object({
TILES_URL: z.string(), TILES_URL: z.string(),
SKEBBY_USER: z.string(), SKEBBY_USER: z.string(),
SKEBBY_PASS: z.string(), SKEBBY_PASS: z.string(),
REVALIDATION_SECRET: z.string(),
}); });
/** /**
@ -73,6 +74,7 @@ const processEnv = {
TILES_URL: process.env.TILES_URL, TILES_URL: process.env.TILES_URL,
SKEBBY_USER: process.env.SKEBBY_USER, SKEBBY_USER: process.env.SKEBBY_USER,
SKEBBY_PASS: process.env.SKEBBY_PASS, SKEBBY_PASS: process.env.SKEBBY_PASS,
REVALIDATION_SECRET: process.env.REVALIDATION_SECRET,
}; };
// Don't touch the part below // Don't touch the part below

View file

@ -2,8 +2,10 @@ import { format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
import { import {
CalendarIcon, CalendarIcon,
CodeSquare,
Eye, Eye,
Printer, Printer,
RefreshCcw,
RotateCw, RotateCw,
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
@ -132,9 +134,15 @@ export const AnnuncioEditForm = ({ data }: { data: Annunci }) => {
const { mutateAsync: revalidate } = const { mutateAsync: revalidate } =
api.settings.revalidateAnnuncio.useMutation({ api.settings.revalidateAnnuncio.useMutation({
onSettled: async () => {
toast.success("Revalidazione completata");
},
});
const { mutateAsync: update } = api.settings.updateAnnuncio.useMutation({
onSettled: async () => { onSettled: async () => {
toast.success( toast.success(
"La revalidazione dell'annuncio è stata richiesta con successo,\n potrebbero volerci alcuni minuti prima che le modifiche siano visibili.", "L'aggiornamento dell'annuncio è stata richiesta con successo,\n potrebbero volerci alcuni minuti prima che le modifiche siano visibili.",
{ {
duration: 5000, duration: 5000,
}, },
@ -214,11 +222,20 @@ export const AnnuncioEditForm = ({ data }: { data: Annunci }) => {
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
> >
<div className="mx-auto grid w-full max-w-7xl flex-1 auto-rows-max gap-4"> <div className="mx-auto grid w-full max-w-7xl flex-1 auto-rows-max gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="whitespace-nowrap font-semibold text-2xl tracking-tight"> <h1 className="whitespace-nowrap font-semibold text-2xl tracking-tight">
Annuncio {data.codice} Annuncio {data.codice}
</h1> </h1>
<StatusBadge status={data.stato} /> <StatusBadge status={data.stato} />
<div className="ml-auto flex items-center gap-2">
<Button className="bg-green-500" size="sm" type="submit">
Salva
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href={`/annuncio/${data.codice}`} target="_blank"> <Link href={`/annuncio/${data.codice}`} target="_blank">
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
@ -242,12 +259,35 @@ export const AnnuncioEditForm = ({ data }: { data: Annunci }) => {
<Printer /> Scheda <Printer /> Scheda
</Button> </Button>
</Link> </Link>
<Confirm <Popover>
description="Sei sicuro di voler richiedere la revalidazione dell'annuncio? Questa operazione potrebbe richiedere alcuni minuti prima che le modifiche siano visibili e resetterà eventuali modifiche effettuate dopo la sincronizzazione giornaliera." <PopoverTrigger asChild>
onConfirm={async () => { <Button
await revalidate({ cod: data.codice }); className="flex items-center gap-2"
size="sm"
type="button"
>
<CodeSquare /> Strumenti
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-fit items-center gap-2">
<Button
className="flex items-center gap-2 bg-purple-500"
onClick={async () => {
await revalidate({
cod: data.codice,
});
}} }}
title="Revalidazione Annuncio" size="sm"
type="button"
>
<RefreshCcw /> Rigenera pagina
</Button>
<Confirm
description="Sei sicuro di voler richiedere l'aggiornamento dell'annuncio? Questa operazione potrebbe richiedere alcuni minuti prima che le modifiche siano visibili e resetterà eventuali modifiche effettuate dopo la sincronizzazione giornaliera."
onConfirm={async () => {
await update({ cod: data.codice });
}}
title="Aggiornamento Annuncio"
> >
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
@ -255,16 +295,11 @@ export const AnnuncioEditForm = ({ data }: { data: Annunci }) => {
type="button" type="button"
variant="warning" variant="warning"
> >
<TriangleAlert /> Rivalida <TriangleAlert /> Aggiorna
</Button> </Button>
</Confirm> </Confirm>
<div className="flex items-center gap-2 md:ml-auto"> </PopoverContent>
<Button size="sm" type="button" variant="outline"> </Popover>
Annulla
</Button>
<Button className="bg-green-500" size="sm" type="submit">
Salva
</Button>
</div> </div>
</div> </div>
<div className="grid h-max gap-3 md:grid-cols-[1fr_250px] lg:grid-cols-3"> <div className="grid h-max gap-3 md:grid-cols-[1fr_250px] lg:grid-cols-3">

View file

@ -236,7 +236,7 @@ export async function getStaticProps(
meta, meta,
trpcState: ssg.dehydrate(), trpcState: ssg.dehydrate(),
}, },
revalidate: 1, revalidate: 21600, // 6 hours - ensures fresh data 4x per day
}; };
} }

View file

@ -0,0 +1,31 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "~/env.mjs";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.query.secret !== env.REVALIDATION_SECRET) {
return res.status(401).json({ message: "Invalid token" });
}
const path = req.query.path as string;
if (!path) {
return res.status(400).json({ message: "Missing path parameter" });
}
try {
await res.revalidate(`${path}`);
return res.json({
success: true,
message: `Page ${path} revalidated successfully`,
});
} catch (err) {
console.error("Error revalidating:", err);
return res.status(500).json({
success: false,
message: err instanceof Error ? err.message : "Unknown error",
});
}
}

View file

@ -1,4 +1,4 @@
import { RefreshCcw, TriangleAlert } from "lucide-react"; import { CodeSquare, RefreshCcw, TriangleAlert } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Confirm } from "~/components/confirm"; import { Confirm } from "~/components/confirm";
import { AreaRiservataLayout } from "~/components/Layout"; import { AreaRiservataLayout } from "~/components/Layout";
@ -6,6 +6,11 @@ import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page"; import { Status500 } from "~/components/status-page";
import { AnnunciTable } from "~/components/tables/annunci-tables"; import { AnnunciTable } from "~/components/tables/annunci-tables";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import { generateSSGHelper } from "~/server/utils/ssgHelper"; import { generateSSGHelper } from "~/server/utils/ssgHelper";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@ -13,8 +18,7 @@ import { api } from "~/utils/api";
const Admin_Annunci: NextPageWithLayout = () => { const Admin_Annunci: NextPageWithLayout = () => {
const { data, isLoading, refetch } = api.annunci.getAnnunciList.useQuery(); const { data, isLoading, refetch } = api.annunci.getAnnunciList.useQuery();
const { mutateAsync: revalidate } = const { mutateAsync: update } = api.settings.updateAllAnnunci.useMutation({
api.settings.revalidateAllAnnunci.useMutation({
onSettled: async () => { onSettled: async () => {
toast.success( toast.success(
"La revalidazione dell'annuncio è stata richiesta con successo,\n potrebbero volerci alcuni minuti prima che le modifiche siano visibili.", "La revalidazione dell'annuncio è stata richiesta con successo,\n potrebbero volerci alcuni minuti prima che le modifiche siano visibili.",
@ -24,6 +28,12 @@ const Admin_Annunci: NextPageWithLayout = () => {
); );
}, },
}); });
const { mutateAsync: revalidate } =
api.settings.revalidateAllAnnunci.useMutation({
onSettled: async () => {
toast.success("Revalidazione completata");
},
});
if (isLoading) return <LoadingPage />; if (isLoading) return <LoadingPage />;
if (!data) return <Status500 />; if (!data) return <Status500 />;
return ( return (
@ -39,12 +49,33 @@ const Admin_Annunci: NextPageWithLayout = () => {
> >
<RefreshCcw className="size-6" /> <RefreshCcw className="size-6" />
</button> </button>
<Confirm <Popover>
description="Sei sicuro di voler richiedere la revalidazione gli annunci? Questa operazione potrebbe richiedere alcuni minuti prima che le modifiche siano visibili e resetterà eventuali modifiche effettuate dopo la sincronizzazione giornaliera." <PopoverTrigger asChild>
onConfirm={async () => { <Button
className="flex items-center gap-2"
size="sm"
type="button"
>
<CodeSquare /> Strumenti avanzati
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center gap-2">
<Button
className="flex items-center gap-2 bg-purple-500"
onClick={async () => {
await revalidate(); await revalidate();
}} }}
title="Rivalida tutti gli annunci" size="sm"
type="button"
>
<RefreshCcw /> Rigenera pagine annnunci
</Button>
<Confirm
description="Sei sicuro di voler richiedere l'aggiornamento degli annunci? Questa operazione potrebbe richiedere alcuni minuti prima che le modifiche siano visibili e resetterà eventuali modifiche effettuate dopo la sincronizzazione giornaliera."
onConfirm={async () => {
await update();
}}
title="Aggiornamento Annunci"
> >
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
@ -52,9 +83,11 @@ const Admin_Annunci: NextPageWithLayout = () => {
type="button" type="button"
variant="warning" variant="warning"
> >
<TriangleAlert /> Rivalida tutti gli annunci <TriangleAlert /> Aggiorna tutti gli annunci
</Button> </Button>
</Confirm> </Confirm>
</PopoverContent>
</Popover>
</div> </div>
</div> </div>
<AnnunciTable data={data} /> <AnnunciTable data={data} />

View file

@ -7,6 +7,7 @@ import {
createTRPCRouter, createTRPCRouter,
publicProcedure, publicProcedure,
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { getCodici_AnnunciHandler } from "~/server/controllers/annunci.controller";
import { import {
addEtichetta, addEtichetta,
getAllEtichette, getAllEtichette,
@ -36,6 +37,10 @@ import {
newStringa, newStringa,
updateStringhe, updateStringhe,
} from "~/server/services/testi_stringhe.service"; } from "~/server/services/testi_stringhe.service";
import {
revalidate,
revalidateMultiple,
} from "~/server/utils/revalidationHelper";
import { import {
zEtichettaId, zEtichettaId,
zFlagsId, zFlagsId,
@ -250,22 +255,20 @@ export const settingsRouter = createTRPCRouter({
stringaValue: input.stringaValue, stringaValue: input.stringaValue,
}); });
}), }),
revalidateAllAnnunci: adminProcedure.mutation(async () => { revalidateAllAnnunci: adminProcedure.mutation(async () => {
try { try {
const response = await fetch(`${env.BACKENDSERVER_URL}/update`); const annunciOnline = await getCodici_AnnunciHandler();
const { success, failed } = await revalidateMultiple(
if (!response.ok) { annunciOnline.map((cod) => `/annuncio/${cod}`),
const errorData = await response.text().catch(() => "Unknown error");
throw new Error(
`Revalidation failed with status ${response.status}: ${errorData}`,
); );
console.log(`Successfully revalidated pages: ${success.join(", ")}`);
if (failed.length > 0) {
failed.forEach(({ path, error }) => {
console.error(`Failed to revalidate ${path}: ${error}`);
});
} }
const data = await response.json().catch(() => ({}));
return { return {
status: "success", status: "success",
details: data,
}; };
} catch (err) { } catch (err) {
console.error("Revalidation error:", err); console.error("Revalidation error:", err);
@ -284,21 +287,9 @@ export const settingsRouter = createTRPCRouter({
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const response = await fetch( await revalidate(`/annuncio/${input.cod}`);
`${env.BACKENDSERVER_URL}/update-cod/${input.cod}`,
);
if (!response.ok) {
const errorData = await response.text().catch(() => "Unknown error");
throw new Error(
`Revalidation failed with status ${response.status}: ${errorData}`,
);
}
const data = await response.json().catch(() => ({}));
return { return {
status: "success", status: "success",
details: data,
}; };
} catch (err) { } catch (err) {
console.error("Revalidation error:", err); console.error("Revalidation error:", err);
@ -309,4 +300,74 @@ export const settingsRouter = createTRPCRouter({
}); });
} }
}), }),
updateAllAnnunci: adminProcedure.mutation(async () => {
try {
const response = await fetch(`${env.BACKENDSERVER_URL}/update`);
if (!response.ok) {
const errorData = await response.text().catch(() => "Unknown error");
throw new Error(
`Update failed with status ${response.status}: ${errorData}`,
);
}
const data = await response.json().catch(() => ({}));
const annunciOnline = await getCodici_AnnunciHandler();
const { success, failed } = await revalidateMultiple(
annunciOnline.map((cod) => `/annuncio/${cod}`),
);
console.log(`Successfully revalidated pages: ${success.join(", ")}`);
if (failed.length > 0) {
failed.forEach(({ path, error }) => {
console.error(`Failed to revalidate ${path}: ${error}`);
});
}
return {
status: "success",
details: data,
};
} catch (err) {
console.error("Update error:", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: err instanceof Error ? err.message : "Update failed",
cause: err,
});
}
}),
updateAnnuncio: adminProcedure
.input(
z.object({
cod: z.string(),
}),
)
.mutation(async ({ input }) => {
try {
const response = await fetch(
`${env.BACKENDSERVER_URL}/update-cod/${input.cod}`,
);
if (!response.ok) {
const errorData = await response.text().catch(() => "Unknown error");
throw new Error(
`Update failed with status ${response.status}: ${errorData}`,
);
}
const data = await response.json().catch(() => ({}));
await revalidate(`/annuncio/${input.cod}`);
return {
status: "success",
details: data,
};
} catch (err) {
console.error("Update error:", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: err instanceof Error ? err.message : "Update failed",
cause: err,
});
}
}),
}); });

View file

@ -9,6 +9,7 @@ import type { ServizioServizioId } from "~/schemas/public/Servizio";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { AnnuncioObjectWithImages } from "~/server/services/annunci.service"; import { AnnuncioObjectWithImages } from "~/server/services/annunci.service";
import { createSrcset } from "~/server/services/imageServer"; import { createSrcset } from "~/server/services/imageServer";
import { revalidate } from "../utils/revalidationHelper";
// const ratelimit = new RateLimiterHandler({ // const ratelimit = new RateLimiterHandler({
// windowSize: 10, // windowSize: 10,
@ -371,13 +372,15 @@ export const editAnnuncioHandler = async ({
data: AnnunciUpdate; data: AnnunciUpdate;
}) => { }) => {
try { try {
await db const annuncio = await db
.updateTable("annunci") .updateTable("annunci")
.set({ .set({
...data, ...data,
}) })
.where("id", "=", annuncioId) .where("id", "=", annuncioId)
.execute(); .returning("codice")
.executeTakeFirstOrThrow();
await revalidate(`/annuncio/${annuncio.codice}`);
return true; return true;
} catch (e) { } catch (e) {
throw new TRPCError({ throw new TRPCError({

View file

@ -0,0 +1,56 @@
export const revalidate = async (path: string) => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/revalidation?secret=${process.env.REVALIDATION_SECRET}&path=${encodeURIComponent(path)}`,
{
method: "GET",
},
);
if (!res.ok) {
const errorData = (await res.json()) as {
message: string;
success: boolean;
};
throw new Error(
`Failed to revalidate path ${path}: ${errorData.message}`,
);
}
} catch (error) {
console.error(`Error revalidating path ${path}:`, error);
throw error;
}
};
export const revalidateMultiple = async (
paths: string[],
): Promise<{
success: string[];
failed: Array<{ path: string; error: string }>;
}> => {
const results = await Promise.allSettled(
paths.map((path) => revalidate(path)),
);
const success: string[] = [];
const failed: Array<{ path: string; error: string }> = [];
results.forEach((result, index) => {
const path = paths[index];
if (!path) {
return;
}
if (result.status === "fulfilled") {
success.push(path);
} else {
failed.push({
path,
error:
result.reason instanceof Error
? result.reason.message
: "Unknown error",
});
}
});
return { success, failed };
};