diff --git a/apps/infoalloggi/emails/scheda-contatti.tsx b/apps/infoalloggi/emails/scheda-contatti.tsx new file mode 100644 index 0000000..e6995d0 --- /dev/null +++ b/apps/infoalloggi/emails/scheda-contatti.tsx @@ -0,0 +1,80 @@ +import { Heading, Row, Section, Text } from "@react-email/components"; +import Base from "./base"; +export type ContattiConScheda_NewMail = { + mailType: "emailContattiConScheda"; + props: EmailContattiConSchedaProps; +}; +type EmailContattiConSchedaProps = { + nominativo: string; + telefono: string; + indirizzo: string; + codice: string; +}; + +const EmailContattiConScheda = ({ + nominativo, + telefono, + indirizzo, + codice, +}: EmailContattiConSchedaProps) => { + return ( + + <> + + Dati Contatto Annuncio + +
+ + + I contatti per l'annuncio {codice} sono stati sbloccati con + successo. + + + + Scriva un messaggio WhatsApp ({" "} + + Clicca per aprire WhatsApp + {" "} + ) avvisando di aver ricevuto da Infoalloggi il suo contatto e + chieda un appuntamento, poi ci aggiorniamo per l'esito. + + + Nominativo: {nominativo} + Tel: {telefono} + Indirizzo immobile: {indirizzo} + + + In allegato trova la scheda completa dell'annuncio + +
+ + + Per ulteriori informazioni e chiarimenti non esiti a contattarci + all'indirizzo{" "} + + web@infoalloggi.it + {" "} + oppure tramite tel. 0424529869. + + Cordiali saluti +
+ + {" "} + Le informazioni contenute in questo messaggio di posta elettronica + sono riservate confidenziali e ne è vietata la diffusione in + qualunque modo eseguita. Qualora Lei non fosse la persona a cui il + presente messaggio è destinato, La invitiamo gentilmente ad + eliminarlo dopo averne dato tempestiva comunicazione al mittente e + a non utilizzare in alcun caso il suo contenuto. Qualsiasi + utilizzo non autorizzato di questo messaggio e dei suoi eventuali + allegati espone il responsabile alle relative conseguenze civili e + penali. + +
+
+ + + ); +}; + +export default EmailContattiConScheda; diff --git a/apps/infoalloggi/src/components/schedaAnnuncioStampabile.tsx b/apps/infoalloggi/src/components/schedaAnnuncioStampabile.tsx index 43d3afe..b1aefb2 100644 --- a/apps/infoalloggi/src/components/schedaAnnuncioStampabile.tsx +++ b/apps/infoalloggi/src/components/schedaAnnuncioStampabile.tsx @@ -18,27 +18,16 @@ import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -const blobToBase64 = (blob: Blob): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = reject; - reader.onload = () => { - if (typeof reader.result === "string") { - // Remove the Data-URL prefix (e.g. "data:application/pdf;base64,") to get just the base64 - resolve(reader.result.split(",")[1] || ""); - } else { - reject(new Error("Failed to convert blob to text")); - } - }; - reader.readAsDataURL(blob); - }); -}; - export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) { const [inputEmail, setInputEmail] = useState(""); const [openPopover, setOpenPopover] = useState(false); const { mutate } = api.comunicazioni.sendSchedaAnnuncioEmail.useMutation({ + onMutate: () => { + toast.loading("Invio email in corso...", { + id: "send-scheda-annuncio-email", + }); + }, onSuccess: () => { toast.success("Email inviata con successo!", { id: "send-scheda-annuncio-email", @@ -56,64 +45,32 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) { toast.error("Dati annuncio mancanti."); return; } - try { - toast.loading("Generazione PDF in corso...", { - id: "generate-pdf", - }); - const response = await fetch(`/api/generate-pdf?id=${data.id}`); - if (!response.ok) { - throw new Error( - `Errore nella generazione del PDF: ${response.statusText}`, - ); - } - const blob = await response.blob(); - - const pdfBase64 = await blobToBase64(blob); - - mutate({ - pdf: pdfBase64, - to: inputEmail, - codice: data.codice, - numero: data.numero, - nominativo: data.locatore, - indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`, - }); - setInputEmail(""); - - toast.success("PDF generato con successo!", { - id: "generate-pdf", - }); - } catch (error) { - toast.error( - `Errore durante la generazione del PDF: ${(error as Error).message}`, - { - id: "generate-pdf", - }, - ); - console.error("Error generating PDF:", error); - } + mutate({ + id: data.id, + to: inputEmail, + codice: data.codice, + numero: data.numero, + nominativo: data.locatore, + indirizzo: `${data.indirizzo}, ${data.civico} ${data.comune} ${data.cap} (${data.provincia})`, + }); + setInputEmail(""); }; - const generatePdf = async () => { - if (!data) { - toast.error("Dati annuncio mancanti."); - return; - } - try { + const { mutate: generatePdf } = api.annunci.getSchedaAnnuncio.useMutation({ + onMutate: () => { toast.loading("Generazione PDF in corso...", { id: "generate-pdf", }); - const response = await fetch(`/api/generate-pdf?id=${data.id}`); - if (!response.ok) { - throw new Error( - `Errore nella generazione del PDF: ${response.statusText}`, - ); - } - const blob = await response.blob(); + }, + onSuccess: (res) => { toast.success("PDF generato con successo!", { id: "generate-pdf", }); + + const blob = new Blob([res as unknown as ArrayBuffer], { + type: "application/pdf", + }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; @@ -122,16 +79,13 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) { link.click(); link.remove(); window.URL.revokeObjectURL(url); - } catch (error) { - toast.error( - `Errore durante la generazione del PDF: ${(error as Error).message}`, - { - id: "generate-pdf", - }, - ); - console.error("Error generating PDF:", error); - } - }; + }, + onError: (error) => { + toast.error(`Errore durante la generazione del PDF: ${error.message}`, { + id: "generate-pdf", + }); + }, + }); return (
@@ -139,7 +93,9 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {

Scheda Annuncio Stampabile

- + diff --git a/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx b/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx index eef0b18..4043170 100644 --- a/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx +++ b/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx @@ -4,6 +4,7 @@ import { FileBadge, FileText, FolderKanban, + Mail, PackageCheck, Pointer, Printer, @@ -51,7 +52,8 @@ export const AdminActions = () => { if (!isAdmin) return null; return ( -
+
+ {data.open_contatti_at != null && } {data.user_confirmed_at != null && } {data.accettato_conferma_at != null && ( <> @@ -122,6 +124,46 @@ export const AdminActions = () => { ); }; +const AdminSendScheda = () => { + const { userId } = useServizio(); + const { annuncioId } = useServizioAnnuncio(); + + const { mutate } = api.comunicazioni.sendSchedaAnnuncioServizio.useMutation({ + onMutate: () => { + toast.loading("Invio email in corso...", { + id: "send-scheda-annuncio-email", + }); + }, + onSuccess: () => { + toast.success("Email inviata con successo!", { + id: "send-scheda-annuncio-email", + }); + }, + onError: (error) => { + toast.error(`Errore durante l'invio dell'email: ${error.message}`, { + id: "send-scheda-annuncio-email", + }); + }, + }); + + return ( + + ); +}; + export const UserActions = () => { const { servizioId } = useServizio(); const { data, annuncioId } = useServizioAnnuncio(); diff --git a/apps/infoalloggi/src/pages/api/generate-pdf.ts b/apps/infoalloggi/src/pages/api/generate-pdf.ts deleted file mode 100644 index bfc2d6a..0000000 --- a/apps/infoalloggi/src/pages/api/generate-pdf.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import puppeteer from "puppeteer-core"; -import { env } from "~/env"; - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { - let browser = null; - - try { - browser = await puppeteer.connect({ - browserWSEndpoint: env.BROWSER_WS_URL, - }); - - const page = await browser.newPage(); - - if (req.cookies && Object.keys(req.cookies).length > 0) { - const productionDomain = new URL(env.INTERNAL_BASE_URL).hostname; - - const cookies = Object.entries(req.cookies).map(([name, value]) => ({ - name, - value: value || "", - domain: - env.NODE_ENV === "production" - ? productionDomain - : "host.docker.internal", - path: "/", - })); - await browser.setCookie(...cookies); - } - - const baseUrl = - env.NODE_ENV === "production" - ? env.INTERNAL_BASE_URL - : `http://host.docker.internal:3000`; - const targetUrl = `${baseUrl}/area-riservata/admin/scheda-annuncio-stampa/${req.query.id}?raw=true`; - await page.goto(targetUrl, { waitUntil: "networkidle0" }); - - const pdf = await page.pdf({ - format: "A4", - printBackground: true, - }); - - res.setHeader("Content-Type", "application/pdf"); - res.send(Buffer.from(pdf)); - } catch (error) { - console.error("PDF Generation Error:", error); - res.status(500).json({ error: "Failed to generate PDF" }); - } finally { - // 'disconnect' tells browserless you're done with this tab - if (browser) await browser.disconnect(); - } -} diff --git a/apps/infoalloggi/src/proxies/auth.ts b/apps/infoalloggi/src/proxies/auth.ts index 3966545..d7803bb 100644 --- a/apps/infoalloggi/src/proxies/auth.ts +++ b/apps/infoalloggi/src/proxies/auth.ts @@ -1,5 +1,6 @@ import { add } from "date-fns"; import { type NextRequest, NextResponse } from "next/server"; +import { env } from "~/env"; import type { ProxyFn } from "~/proxy"; import { TOKEN_CONFIG } from "~/server/auth/configs"; @@ -8,6 +9,15 @@ import { signToken, verifyToken } from "~/server/auth/jwt"; export const authProxy: ProxyFn = async (req: NextRequest) => { const path = req.nextUrl.pathname; + // permette a puppeteer di accedere alla pagina di generazione PDF bypassando l'autenticazione normale + if (path.startsWith("/area-riservata/admin/scheda-annuncio-stampa/")) { + const overrideToken = req.cookies.get( + TOKEN_CONFIG.OVERRIDE_TOKEN_COOKIE_NAME, + )?.value; + if (overrideToken && overrideToken === env.EXP_API_PASS) { + return; + } + } const refreshToken = req.cookies.get( TOKEN_CONFIG.REFRESH_TOKEN_COOKIE_NAME, )?.value; @@ -17,9 +27,7 @@ export const authProxy: ProxyFn = async (req: NextRequest) => { )?.value; const isProtectedRoute = - (path.startsWith("/area-riservata") || - path.startsWith("/servizio") || - path.startsWith("/api/generate-pdf")) && + (path.startsWith("/area-riservata") || path.startsWith("/servizio")) && !path.startsWith("/servizio/pre-onboard"); if (isProtectedRoute) { diff --git a/apps/infoalloggi/src/server/api/routers/annunci.ts b/apps/infoalloggi/src/server/api/routers/annunci.ts index d07e596..416828d 100644 --- a/apps/infoalloggi/src/server/api/routers/annunci.ts +++ b/apps/infoalloggi/src/server/api/routers/annunci.ts @@ -1,3 +1,4 @@ +import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import type { AnnunciUpdate } from "~/schemas/public/Annunci"; import { @@ -25,7 +26,9 @@ 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 { zAnnuncioId, zServizioId } from "~/server/utils/zod_types"; +import { getKeydbClient } from "~/utils/keydb"; export const annunciRouter = createTRPCRouter({ editAnnuncio: adminProcedure @@ -33,6 +36,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}`); + } return await editAnnuncioHandler({ ...input, }); @@ -152,4 +159,20 @@ export const annunciRouter = createTRPCRouter({ ...input, }); }), + getSchedaAnnuncio: adminProcedure + .input( + z.object({ + id: zAnnuncioId, + }), + ) + .mutation(async ({ input }) => { + try { + return await genPdfSchedaAnnuncio(input.id); + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Errore nella generazione della scheda: ${(e as Error).message}`, + }); + } + }), }); diff --git a/apps/infoalloggi/src/server/api/routers/comunicazioni.ts b/apps/infoalloggi/src/server/api/routers/comunicazioni.ts index b4734ea..c84c67f 100644 --- a/apps/infoalloggi/src/server/api/routers/comunicazioni.ts +++ b/apps/infoalloggi/src/server/api/routers/comunicazioni.ts @@ -12,6 +12,7 @@ import { getEmails, } from "~/server/services/email.service"; import { NewMail } from "~/server/services/mailer"; +import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service"; import { zAnnuncioId, zUserId } from "~/server/utils/zod_types"; export const comunicazioniRouter = createTRPCRouter({ @@ -54,7 +55,7 @@ export const comunicazioniRouter = createTRPCRouter({ sendSchedaAnnuncioEmail: adminProcedure .input( z.object({ - pdf: z.string(), + id: zAnnuncioId, codice: z.string(), numero: z.string(), nominativo: z.string(), @@ -64,9 +65,7 @@ export const comunicazioniRouter = createTRPCRouter({ ) .mutation(async ({ input }) => { try { - const base64Content = input.pdf.includes("base64,") - ? input.pdf.split("base64,")[1] - : input.pdf; + const scheda = await genPdfSchedaAnnuncioBase64(input.id); await NewMail({ template: { @@ -83,7 +82,7 @@ export const comunicazioniRouter = createTRPCRouter({ attachments: [ { filename: `scheda annuncio ${input.codice}.pdf`, - content: base64Content, + content: scheda, encoding: "base64", contentType: "application/pdf", }, @@ -110,6 +109,64 @@ export const comunicazioniRouter = createTRPCRouter({ ); } }), + sendSchedaAnnuncioServizio: adminProcedure + .input( + z.object({ + userId: zUserId, + annuncioId: zAnnuncioId, + }), + ) + .mutation(async ({ input }) => { + try { + const scheda = await genPdfSchedaAnnuncioBase64(input.annuncioId); + const user = await db + .selectFrom("users") + .select("email") + .where("id", "=", input.userId) + .executeTakeFirstOrThrow(); + const annuncio = await db + .selectFrom("annunci") + .select([ + "codice", + "locatore", + "indirizzo", + "civico", + "comune", + "cap", + "provincia", + "numero", + ]) + .where("id", "=", input.annuncioId) + .executeTakeFirstOrThrow(); + await NewMail({ + template: { + mailType: "emailContattiConScheda", + props: { + codice: annuncio.codice, + nominativo: annuncio.locatore || "", + telefono: annuncio.numero || "", + indirizzo: `${annuncio.indirizzo || ""}, ${annuncio.civico || ""} ${annuncio.comune || ""} ${annuncio.cap || ""} (${annuncio.provincia || ""})`, + }, + }, + mail: { + subject: `Contatti Annuncio ${annuncio.codice} - Infoalloggi.it`, + to: user.email, + attachments: [ + { + filename: `scheda annuncio ${annuncio.codice}.pdf`, + content: scheda, + encoding: "base64", + contentType: "application/pdf", + }, + ], + }, + }); + } catch (e) { + throw new Error( + `Errore nell'invio dell'email: ${(e as Error).message}`, + ); + } + }), sendAnnunciEmail: adminProcedure .input( z.object({ diff --git a/apps/infoalloggi/src/server/auth/configs.ts b/apps/infoalloggi/src/server/auth/configs.ts index 2f33d68..2b3a199 100644 --- a/apps/infoalloggi/src/server/auth/configs.ts +++ b/apps/infoalloggi/src/server/auth/configs.ts @@ -1,6 +1,7 @@ export const TOKEN_CONFIG = { ACCESS_TOKEN_COOKIE_NAME: "access_token", REFRESH_TOKEN_COOKIE_NAME: "refresh_token", + OVERRIDE_TOKEN_COOKIE_NAME: "override_token", ACCESS_TOKEN_EXPIRY: "1h", REFRESH_TOKEN_EXPIRY: "7d", ACCESS_TOKEN_COOKIE_EXPIRY: 1, diff --git a/apps/infoalloggi/src/server/controllers/servizio.controller.ts b/apps/infoalloggi/src/server/controllers/servizio.controller.ts index a6a33e1..ef18709 100644 --- a/apps/infoalloggi/src/server/controllers/servizio.controller.ts +++ b/apps/infoalloggi/src/server/controllers/servizio.controller.ts @@ -53,6 +53,7 @@ import { } from "~/utils/kysely-helper"; import { GetUserInterests } from "../services/interests.service"; import { getPrezziarioByIdHandler } from "../services/prezziario.service"; +import { genPdfSchedaAnnuncioBase64 } from "../services/puppeteer.service"; import type { AnnuncioRicerca } from "./annunci.controller"; export const addServizio = async (data: NewServizio) => { @@ -1308,20 +1309,25 @@ export const SbloccaContatti = async ({ const EmailInteressato: NewMailProps = { template: { - mailType: "generic", + mailType: "emailContattiConScheda", props: { - link: { - href: `${env.BASE_URL}/area-riservata/dashboard#${servizioId}-${annuncioId}`, - label: "Vai al servizio", - }, - noreply: true, - testo: `I contatti per l'annuncio ${data.annuncio.codice} sono stati sbloccati con successo.`, - title: "Contatti sbloccati per il servizio di ricerca affitto", + nominativo: data.annuncio.locatore || "", + telefono: data.annuncio.numero || "", + indirizzo: data.annuncio.indirizzo || "", + codice: data.annuncio.codice, }, }, mail: { - subject: "Contatti sbloccati per il servizio di ricerca affitto", + subject: `Contatti Annuncio ${data.annuncio.codice} - InfoAlloggi`, to: data.user.email, + attachments: [ + { + filename: `scheda annuncio ${data.annuncio.codice}.pdf`, + content: await genPdfSchedaAnnuncioBase64(data.annuncioId), + encoding: "base64", + contentType: "application/pdf", + }, + ], }, userId: data.user.id, }; diff --git a/apps/infoalloggi/src/server/services/mailer.ts b/apps/infoalloggi/src/server/services/mailer.ts index cbd746b..e52505f 100644 --- a/apps/infoalloggi/src/server/services/mailer.ts +++ b/apps/infoalloggi/src/server/services/mailer.ts @@ -27,6 +27,8 @@ import RegistrazioneAvvenuta from "emails/registrazione-avvenuta"; import EmailSchedaContatto, { type SchedaContatto_NewMail, } from "emails/scheda-annuncio"; +import type { ContattiConScheda_NewMail } from "emails/scheda-contatti"; +import EmailContattiConScheda from "emails/scheda-contatti"; import ShareAnnunci, { type ShareAnnunci_NewMail } from "emails/share-annunci"; import VerificaEmail, { type VerificaEmail_NewMail, @@ -90,7 +92,8 @@ export type MailsTemplates = | ShareAnnunci_NewMail | Generic_NewMail | SchedaContatto_NewMail - | Invito_NewMail; + | Invito_NewMail + | ContattiConScheda_NewMail; export const genMail = async ({ ...mail }: MailsTemplates) => { let mailComponent: JSX.Element | null = null; @@ -182,6 +185,12 @@ export const genMail = async ({ ...mail }: MailsTemplates) => { }); title = "Invito al sito"; break; + case "emailContattiConScheda": + mailComponent = EmailContattiConScheda({ + ...mail.props, + }); + title = "Contatti Con Scheda"; + break; default: throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/apps/infoalloggi/src/server/services/puppeteer.service.ts b/apps/infoalloggi/src/server/services/puppeteer.service.ts new file mode 100644 index 0000000..7834172 --- /dev/null +++ b/apps/infoalloggi/src/server/services/puppeteer.service.ts @@ -0,0 +1,86 @@ +import { TRPCError } from "@trpc/server"; +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 genPdfSchedaAnnuncioBase64 = async (annuncioId: AnnunciId) => { + const pdf = await genPdfSchedaAnnuncio(annuncioId); + const pdfBase64 = Buffer.from(pdf).toString("base64"); + const base64Content = pdfBase64.includes("base64,") + ? pdfBase64.split("base64,")[1] + : pdfBase64; + if (!base64Content) { + throw new Error("Failed to convert PDF to base64"); + } + return base64Content; +}; + +export const genPdfSchedaAnnuncio = async (annuncioId: AnnunciId) => { + const keybd = getKeydbClient(); + if (keybd) { + const cacheKey = `scheda-annuncio-${annuncioId}`; + const cached = await keybd.get(cacheKey); + if (cached) { + return new Uint8Array(Buffer.from(cached, "base64")); + } + const scheda = await puppeteerSchedaAnnuncio(annuncioId); + await keybd.set( + cacheKey, + Buffer.from(scheda).toString("base64"), + "EX", + 3600 * 5, + ); + return scheda; + } + return await puppeteerSchedaAnnuncio(annuncioId); +}; + +const puppeteerSchedaAnnuncio = async (annuncioId: AnnunciId) => { + let browser = null; + + try { + browser = await puppeteer.connect({ + browserWSEndpoint: env.BROWSER_WS_URL, + }); + + const page = await browser.newPage(); + + const productionDomain = new URL(env.INTERNAL_BASE_URL).hostname; + const newCookies = [ + { + name: TOKEN_CONFIG.OVERRIDE_TOKEN_COOKIE_NAME, + value: env.EXP_API_PASS, + domain: + env.NODE_ENV === "production" + ? productionDomain + : "host.docker.internal", + path: "/", + }, + ]; + await browser.setCookie(...newCookies); + + const baseUrl = + env.NODE_ENV === "production" + ? env.INTERNAL_BASE_URL + : `http://host.docker.internal:3000`; + + const targetUrl = `${baseUrl}/area-riservata/admin/scheda-annuncio-stampa/${annuncioId}?raw=true`; + await page.goto(targetUrl, { waitUntil: "networkidle0" }); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + }); + return pdf; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Errore nella generazione del PDF: ${(error as Error).message}`, + }); + } finally { + // 'disconnect' tells browserless you're done with this tab + if (browser) await browser.disconnect(); + } +};