From 6c9524e54b65a7b8c0fa6fa2022ccf5719a1e3ff Mon Sep 17 00:00:00 2001 From: Marco Pedone Date: Wed, 4 Mar 2026 17:34:15 +0100 Subject: [PATCH] feat: add Redis cache invalidation and stats retrieval functionality --- .../area-riservata/admin/impostazioni.tsx | 117 +++++------------- .../src/server/api/routers/annunci.ts | 15 ++- .../src/server/api/routers/revalidation.ts | 23 ++++ .../src/server/services/puppeteer.service.ts | 94 +++++++++++++- 4 files changed, 154 insertions(+), 95 deletions(-) diff --git a/apps/infoalloggi/src/pages/area-riservata/admin/impostazioni.tsx b/apps/infoalloggi/src/pages/area-riservata/admin/impostazioni.tsx index f143f5b..0c96009 100644 --- a/apps/infoalloggi/src/pages/area-riservata/admin/impostazioni.tsx +++ b/apps/infoalloggi/src/pages/area-riservata/admin/impostazioni.tsx @@ -1,14 +1,9 @@ import Head from "next/head"; -import { useState } from "react"; import toast from "react-hot-toast"; -import Input from "~/components/custom_ui/input"; -import { MultiSelect } from "~/components/custom_ui/multiselect"; import { AreaRiservataLayout } from "~/components/Layout"; import { LoadingPage } from "~/components/loading"; import { Button } from "~/components/ui/button"; -import { Label } from "~/components/ui/label"; import type { NextPageWithLayout } from "~/pages/_app"; -import type { AnnunciId } from "~/schemas/public/Annunci"; import { api } from "~/utils/api"; const Impostazioni: NextPageWithLayout = () => { @@ -25,7 +20,7 @@ const Impostazioni: NextPageWithLayout = () => { - + ); @@ -77,90 +72,44 @@ const StripeSection = () => { ); }; -const SendEmailSection = () => { - const { data: codici, isLoading: loading } = - api.annunci.getOnline_AnnuncioIdCodice.useQuery(); - const [selectedAnnunci, setSelectedAnnunci] = useState< - { value: AnnunciId; label: string }[] - >([]); - const [email, setEmail] = useState(undefined); - - const { mutate } = api.comunicazioni.sendAnnunciEmail.useMutation({ - onSuccess: () => { - // setSelectedAnnunci([]); - // setEmail(""); - toast.success("Email inviata con successo"); +const RedisCacheSection = () => { + const { data, isLoading } = api.revalidation.getRedisStats.useQuery(); + const utils = api.useUtils(); + const { mutate } = api.revalidation.invalidateRedisCache.useMutation({ + onMutate() { + toast.loading("Invalidating cache...", { + id: "invalidate-cache", + }); }, - onError: (error) => { - console.error(error); - toast.error(`Errore durante l'invio dell'email: ${error.message}`); + onSuccess: async () => { + toast.success("Cache invalidated successfully!", { + id: "invalidate-cache", + }); + await utils.revalidation.getRedisStats.invalidate(); + }, + onError() { + toast.error("Failed to invalidate cache.", { + id: "invalidate-cache", + }); }, }); - + if (isLoading) { + return ; + } return (
-

- Invia email con annunci selezionati -

+

Invalidate Redis Cache

+

Current Cache Stats:

+ {data?.success ? ( +
    +
  • Pdf Schede Annuncio: {data?.schedaCount}
  • +
  • Pdf Condizioni: {data?.condizioniCount}
  • +
+ ) : ( +
Error fetching cache stats
+ )} -
- - setEmail(e.target.value)} - placeholder="Inserisci l'indirizzo email" - type="email" - value={email || ""} - /> -
-
- {loading ? ( - - ) : ( -
- - { - setSelectedAnnunci( - value.map((v) => ({ - label: v.label, - value: parseInt(v.value) as AnnunciId, - })), - ); - }} - options={ - codici?.map((cod) => ({ - label: cod.codice, - value: cod.id.toString(), - })) || [] - } - placeholder="" - /> -
- )} -
- +
); }; diff --git a/apps/infoalloggi/src/server/api/routers/annunci.ts b/apps/infoalloggi/src/server/api/routers/annunci.ts index 416828d..4139e39 100644 --- a/apps/infoalloggi/src/server/api/routers/annunci.ts +++ b/apps/infoalloggi/src/server/api/routers/annunci.ts @@ -26,9 +26,12 @@ import { } from "~/server/controllers/annunci.controller"; import { db } from "~/server/db"; import { NewMail } from "~/server/services/mailer"; -import { genPdfSchedaAnnuncio } from "~/server/services/puppeteer.service"; +import { + genPdfSchedaAnnuncio, + invalidateCache, + SCHEDA_ANNUNCIO_CACHE_PREFIX, +} from "~/server/services/puppeteer.service"; import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types"; -import { getKeydbClient } from "~/utils/keydb"; export const annunciRouter = createTRPCRouter({ editAnnuncio: adminProcedure @@ -36,10 +39,10 @@ export const annunciRouter = createTRPCRouter({ z.object({ annuncioId: zAnnuncioId, data: z.custom() }), ) .mutation(async ({ input }) => { - const keybd = getKeydbClient(); - if (keybd) { - await keybd.del(`scheda-annuncio-${input.annuncioId}`); - } + await invalidateCache( + `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${input.annuncioId}`, + ); + return await editAnnuncioHandler({ ...input, }); diff --git a/apps/infoalloggi/src/server/api/routers/revalidation.ts b/apps/infoalloggi/src/server/api/routers/revalidation.ts index 1031e0a..27cb209 100644 --- a/apps/infoalloggi/src/server/api/routers/revalidation.ts +++ b/apps/infoalloggi/src/server/api/routers/revalidation.ts @@ -3,6 +3,10 @@ import { z } from "zod/v4"; import { env } from "~/env"; import { adminProcedure, createTRPCRouter } from "~/server/api/trpc"; import { getCodici_AnnunciHandler } from "~/server/controllers/annunci.controller"; +import { + getCacheStats, + invalidateAllCaches, +} from "~/server/services/puppeteer.service"; import { revalidate, revalidateMultiple, @@ -130,4 +134,23 @@ export const revalidationRouter = createTRPCRouter({ }); } }), + getRedisStats: adminProcedure.query(async () => { + return await getCacheStats(); + }), + invalidateRedisCache: adminProcedure.mutation(async () => { + try { + await invalidateAllCaches(); + return { + status: "success", + }; + } catch (err) { + console.error("Invalidate Redis cache error:", err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + err instanceof Error ? err.message : "Invalidate Redis cache failed", + cause: err, + }); + } + }), }); diff --git a/apps/infoalloggi/src/server/services/puppeteer.service.ts b/apps/infoalloggi/src/server/services/puppeteer.service.ts index 7834172..2dee39e 100644 --- a/apps/infoalloggi/src/server/services/puppeteer.service.ts +++ b/apps/infoalloggi/src/server/services/puppeteer.service.ts @@ -1,10 +1,72 @@ import { TRPCError } from "@trpc/server"; +import type Redis from "ioredis"; import puppeteer from "puppeteer-core"; import { env } from "~/env"; import type { AnnunciId } from "~/schemas/public/Annunci"; import { getKeydbClient } from "~/utils/keydb"; import { TOKEN_CONFIG } from "../auth/configs"; +export const SCHEDA_ANNUNCIO_CACHE_PREFIX = "scheda-annuncio-"; +export const CONDIZIONI_CACHE_PREFIX = "condizioni-"; +export const invalidateCache = async (cacheKey: string) => { + const keybd = getKeydbClient(); + if (keybd) { + await keybd.del(cacheKey); + } +}; + +async function deleteByPrefix(redis: Redis, prefix: string) { + // Create a stream to find keys matching the pattern + const stream = redis.scanStream({ + match: `${prefix}*`, + count: 100, // Process 100 keys per iteration + }); + + stream.on("data", async (keys: string[]) => { + if (keys.length > 0) { + // Create a pipeline to delete keys in a single network round-trip + const pipeline = redis.pipeline(); + + // Use UNLINK instead of DEL for better performance on large keys + keys.forEach((key) => pipeline.unlink(key)); + + await pipeline.exec(); + console.log(`Deleted ${keys.length} keys`); + } + }); + + stream.on("error", (err) => { + console.error( + `Error scanning/unlinking keys with prefix ${prefix}: ${err.message}`, + ); + }); +} + +export const invalidateAllCaches = async () => { + const keybd = getKeydbClient(); + if (keybd) { + await deleteByPrefix(keybd, SCHEDA_ANNUNCIO_CACHE_PREFIX); + await deleteByPrefix(keybd, CONDIZIONI_CACHE_PREFIX); + } +}; + +export const getCacheStats = async () => { + const keybd = getKeydbClient(); + if (keybd) { + const schedaKeys = await keybd.keys(`${SCHEDA_ANNUNCIO_CACHE_PREFIX}*`); + const condizioniKeys = await keybd.keys(`${CONDIZIONI_CACHE_PREFIX}*`); + return { + success: true, + schedaCount: schedaKeys.length, + condizioniCount: condizioniKeys.length, + }; + } + return { + success: false, + schedaCount: 0, + condizioniCount: 0, + }; +}; export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => { const pdf = await genPdfSchedaAnnuncio(annuncioId); const pdfBase64 = Buffer.from(pdf).toString("base64"); @@ -19,13 +81,14 @@ export const genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => { export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => { const keybd = getKeydbClient(); + const url = `/area-riservata/admin/scheda-annuncio-stampa/${annuncioId}?raw=true`; if (keybd) { - const cacheKey = `scheda-annuncio-${annuncioId}`; + const cacheKey = `${SCHEDA_ANNUNCIO_CACHE_PREFIX}${annuncioId}`; const cached = await keybd.get(cacheKey); if (cached) { return new Uint8Array(Buffer.from(cached, "base64")); } - const scheda = await puppeteerSchedaAnnuncio(annuncioId); + const scheda = await puppeteerGenPdf(url); await keybd.set( cacheKey, Buffer.from(scheda).toString("base64"), @@ -34,10 +97,31 @@ export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => { ); return scheda; } - return await puppeteerSchedaAnnuncio(annuncioId); + return await puppeteerGenPdf(url); }; -const puppeteerSchedaAnnuncio = async (annuncioId: AnnunciId) => { +export const genPdfCondizioni = async (condizioniId: AnnunciId) => { + const keybd = getKeydbClient(); + const url = `/servizio/condizioni/${condizioniId}?raw=true`; + if (keybd) { + const cacheKey = `${CONDIZIONI_CACHE_PREFIX}${condizioniId}`; + const cached = await keybd.get(cacheKey); + if (cached) { + return new Uint8Array(Buffer.from(cached, "base64")); + } + const scheda = await puppeteerGenPdf(url); + await keybd.set( + cacheKey, + Buffer.from(scheda).toString("base64"), + "EX", + 3600 * 24 * 5, + ); + return scheda; + } + return await puppeteerGenPdf(url); +}; + +const puppeteerGenPdf = async (url: string) => { let browser = null; try { @@ -66,7 +150,7 @@ const puppeteerSchedaAnnuncio = async (annuncioId: AnnunciId) => { ? env.INTERNAL_BASE_URL : `http://host.docker.internal:3000`; - const targetUrl = `${baseUrl}/area-riservata/admin/scheda-annuncio-stampa/${annuncioId}?raw=true`; + const targetUrl = `${baseUrl}${url}`; await page.goto(targetUrl, { waitUntil: "networkidle0" }); const pdf = await page.pdf({