feat: implement email functionality for contact details with attached PDF scheda

This commit is contained in:
Marco Pedone 2026-03-04 16:39:27 +01:00
parent 783dc137ea
commit a36fd52af2
11 changed files with 363 additions and 149 deletions

View file

@ -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 (
<Base noreply={true} preview="Dati Contatto Annuncio - Infoalloggi.it">
<>
<Heading className="text-center text-3xl leading-8">
Dati Contatto Annuncio
</Heading>
<Section className="px-5 text-left md:px-12">
<Row>
<Text className="text-base">
I contatti per l'annuncio {codice} sono stati sbloccati con
successo.
</Text>
<Text className="text-base">
Scriva un messaggio WhatsApp ({" "}
<a href={`https://wa.me/${telefono}`} rel="noopener noreferrer">
Clicca per aprire WhatsApp
</a>{" "}
) avvisando di aver ricevuto da Infoalloggi il suo contatto e
chieda un appuntamento, poi ci aggiorniamo per l&apos;esito.
</Text>
<Text className="text-base">Nominativo: {nominativo}</Text>
<Text className="text-base">Tel: {telefono}</Text>
<Text className="text-base">Indirizzo immobile: {indirizzo}</Text>
<Text className="text-base">
In allegato trova la scheda completa dell&apos;annuncio
</Text>
<br />
<Text className="text-base">
Per ulteriori informazioni e chiarimenti non esiti a contattarci
all&apos;indirizzo{" "}
<a href="mailto:web@infoalloggi.it" rel="noopener noreferrer">
web@infoalloggi.it
</a>{" "}
oppure tramite tel. 0424529869.
</Text>
<Text className="text-base">Cordiali saluti</Text>
<br />
<Text className="text-xs">
{" "}
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.
</Text>
</Row>
</Section>
</>
</Base>
);
};
export default EmailContattiConScheda;

View file

@ -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<string> => {
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<string>("");
const [openPopover, setOpenPopover] = useState<boolean>(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,23 +45,9 @@ 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,
id: data.id,
to: inputEmail,
codice: data.codice,
numero: data.numero,
@ -80,40 +55,22 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
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);
}
};
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 (
<div className="mx-auto max-w-4xl">
@ -139,7 +93,9 @@ export function SchedaAnnuncioStampabile({ data }: { data: Annunci }) {
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="font-bold text-2xl">Scheda Annuncio Stampabile</h1>
<div className="flex gap-2">
<Button onClick={() => generatePdf()}>Scarica Scheda</Button>
<Button onClick={() => generatePdf({ id: data.id })}>
Scarica Scheda
</Button>
<Popover onOpenChange={setOpenPopover} open={openPopover}>
<PopoverTrigger asChild>

View file

@ -4,6 +4,7 @@ import {
FileBadge,
FileText,
FolderKanban,
Mail,
PackageCheck,
Pointer,
Printer,
@ -51,7 +52,8 @@ export const AdminActions = () => {
if (!isAdmin) return null;
return (
<div className="flex w-full max-w-fit flex-wrap justify-start gap-3 sm:justify-end">
<div className="flex w-full max-w-fit flex-wrap justify-start gap-3">
{data.open_contatti_at != null && <AdminSendScheda />}
{data.user_confirmed_at != null && <AdminLavoraConferma />}
{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 (
<Button
aria-label="Invia scheda annuncio via email"
className="w-fit"
onClick={() => {
mutate({
annuncioId,
userId,
});
}}
variant="outline"
>
<Mail />
<span>Re-Invia email contatti</span>
</Button>
);
};
export const UserActions = () => {
const { servizioId } = useServizio();
const { data, annuncioId } = useServizioAnnuncio();

View file

@ -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();
}
}

View file

@ -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) {

View file

@ -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<AnnunciUpdate>() }),
)
.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}`,
});
}
}),
});

View file

@ -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({

View file

@ -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,

View file

@ -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,
};

View file

@ -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",

View file

@ -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();
}
};