feat: add email queue management features

- Implemented FormNewMail for creating new email entries in the queue.
- Enhanced AdminPotenziali page layout for better responsiveness.
- Added Coda component to manage user email queues, including adding, removing, and clearing emails.
- Updated API routes for managing email queue operations, including adding, removing, and clearing user email queues.
- Introduced new mail templates and integrated them into the mailer service.
- Improved error handling in the email queue controller and service.
- Added Zod validation for event IDs in the utility types.
This commit is contained in:
Marco Pedone 2026-04-03 11:51:55 +02:00
parent 2acd6eca92
commit 964abfbf46
29 changed files with 1178 additions and 209 deletions

View file

@ -1,18 +1,20 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import { env } from "~/env"; import { env } from "~/env";
import Base from "./base"; import Base from "./base";
export type VerificaEmail_NewMail = {
mailType: "verificaEmail";
props: VerificaEmailProps;
};
type VerificaEmailProps = { export const VerificaEmailMailTag = "verificaEmail";
token: string; export const VerificaEmailPropsSchema = z.object({
userId: string; token: z.string(),
redirect?: string; userId: z.string(),
}; redirect: z.string().optional(),
});
const VerificaEmail = ({ token, userId, redirect }: VerificaEmailProps) => { const VerificaEmail = ({
token,
userId,
redirect,
}: z.infer<typeof VerificaEmailPropsSchema>) => {
return ( return (
<Base noreply preview="Verifica la tua email"> <Base noreply preview="Verifica la tua email">
<> <>

View file

@ -1,8 +1,7 @@
import { Heading, Link, Row, Section, Text } from "@react-email/components"; import { Heading, Link, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type AvvisoInterruzione_NewMail = {
mailType: "avvisoInterruzione"; export const AvvisoInterruzioneMailTag = "avvisoInterruzione";
};
const AvvisoInterruzione = () => { const AvvisoInterruzione = () => {
return ( return (

View file

@ -1,25 +1,24 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base"; import Base from "./base";
export type ContattoAdmin_NewMail = { export const ContattoAdminMailTag = "contattoAdminEmail";
mailType: "contattoAdminEmail";
props: ContattoAdminEmailProps; export const ContattoAdminEmailPropsSchema = z.object({
}; nome: z.string(),
cognome: z.string(),
email: z.email(),
telefono: z.string(),
messaggio: z.string(),
});
type ContattoAdminEmailProps = {
nome: string;
cognome: string;
email: string;
telefono: string;
messaggio: string;
};
const ContattoAdminEmail = ({ const ContattoAdminEmail = ({
nome, nome,
cognome, cognome,
email, email,
telefono, telefono,
messaggio, messaggio,
}: ContattoAdminEmailProps) => { }: z.infer<typeof ContattoAdminEmailPropsSchema>) => {
return ( return (
<Base preview="Contatto Email"> <Base preview="Contatto Email">
<> <>

View file

@ -1,26 +1,25 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import { env } from "~/env"; import { env } from "~/env";
import Base from "./base"; import Base from "./base";
export type ContattoAnnuncio_NewMail = { export const ContattoAnnuncioMailTag = "contattoAnnuncioEmail";
mailType: "contattoAnnuncioEmail";
props: ContattoAnnuncioEmailProps; export const ContattoAnnuncioPropsSchema = z.object({
}; nome: z.string(),
codice: z.string(),
email: z.email(),
telefono: z.string(),
messaggio: z.string(),
});
type ContattoAnnuncioEmailProps = {
nome: string;
codice: string;
email: string;
telefono: string;
messaggio: string;
};
const ContattoAnnuncioEmail = ({ const ContattoAnnuncioEmail = ({
nome, nome,
codice, codice,
email, email,
telefono, telefono,
messaggio, messaggio,
}: ContattoAnnuncioEmailProps) => { }: z.infer<typeof ContattoAnnuncioPropsSchema>) => {
return ( return (
<Base preview="Contatto Email"> <Base preview="Contatto Email">
<> <>

View file

@ -1,16 +1,15 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base"; import Base from "./base";
export type ContattoInteressato_NewMail = {
mailType: "emailContattoInteressato"; export const ContattoInteressatoMailTag = "emailContattoInteressato";
props: EmailContattoInteressatoProps; export const ContattoInteressatoPropsSchema = z.object({
}; nome_cognome: z.string(),
type EmailContattoInteressatoProps = { nome_cognome_utente: z.string(),
nome_cognome: string; telefono: z.string(),
nome_cognome_utente: string; indirizzo: z.string().nullable(),
telefono: string; sesso: z.enum(["M", "F"]).nullable(),
indirizzo: string | null; });
sesso: string | null;
};
const EmailContattoInteressato = ({ const EmailContattoInteressato = ({
nome_cognome, nome_cognome,
@ -18,7 +17,7 @@ const EmailContattoInteressato = ({
telefono, telefono,
indirizzo, indirizzo,
sesso, sesso,
}: EmailContattoInteressatoProps) => { }: z.infer<typeof ContattoInteressatoPropsSchema>) => {
return ( return (
<Base preview="Contatto Email"> <Base preview="Contatto Email">
<> <>

View file

@ -1,27 +1,33 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import z from "zod";
import Base from "./base"; import Base from "./base";
export type Generic_NewMail = {
mailType: "generic";
props: GenericProps;
};
type Corpo = {
testo?: undefined;
corpo: ReactNode;
};
type Testo = {
testo: string;
corpo?: undefined;
};
type GenericProps = {
title: string;
noreply?: boolean;
link?: { export const GenericMailTag = "generic";
href: string;
label?: string; const props_base = z.object({
}; title: z.string(),
} & (Corpo | Testo); noreply: z.boolean().optional(),
link: z
.object({
href: z.string(),
label: z.string().optional(),
})
.optional(),
});
const props_content = z.union([
z.object({
corpo: z.custom<ReactNode>(),
testo: z.undefined().optional(),
}),
z.object({
testo: z.string(),
corpo: z.undefined().optional(),
}),
]);
export const GenericPropsSchema = props_base.and(props_content);
const Generic = ({ const Generic = ({
title, title,
@ -29,7 +35,7 @@ const Generic = ({
testo, testo,
corpo, corpo,
link = undefined, link = undefined,
}: GenericProps) => { }: z.infer<typeof GenericPropsSchema>) => {
return ( return (
<Base noreply={noreply} preview={title}> <Base noreply={noreply} preview={title}>
<> <>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type Interruzione_NewMail = {
mailType: "interruzione"; export const InterruzioneMailTag = "interruzione";
};
const Interruzione = () => { const Interruzione = () => {
return ( return (

View file

@ -1,23 +1,21 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base"; import Base from "./base";
export type Invito_NewMail = {
mailType: "invito";
props: InvitoProps;
};
type InvitoProps = { export const InvitoMailTag = "invito";
nome: string; export const InvitoPropsSchema = z.object({
//email: string; nome: z.string(),
//password: string; //email: z.email(),
inviteUrl: string; //password: z.string(),
}; inviteUrl: z.url(),
});
const Invito = ({ const Invito = ({
nome, nome,
inviteUrl, inviteUrl,
//email, //email,
//password //password
}: InvitoProps) => { }: z.infer<typeof InvitoPropsSchema>) => {
return ( return (
<Base preview="Procedi ora su Infoalloggi.it"> <Base preview="Procedi ora su Infoalloggi.it">
<> <>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type PagamentoConferma_NewMail = {
mailType: "pagamentoConferma"; export const PagamentoConfermaMailTag = "pagamentoConferma";
};
const PagamentoConferma = () => { const PagamentoConferma = () => {
return ( return (

View file

@ -1,9 +1,8 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import { env } from "~/env"; import { env } from "~/env";
import Base from "./base"; import Base from "./base";
export type PagamentoErrore_NewMail = {
mailType: "pagamentoErrore"; export const PagamentoErroreMailTag = "pagamentoErrore";
};
const PagamentoErrore = () => { const PagamentoErrore = () => {
return ( return (

View file

@ -1,9 +1,8 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import { env } from "~/env"; import { env } from "~/env";
import Base from "./base"; import Base from "./base";
export type PagamentoRicezione_NewMail = {
mailType: "pagamentoRicezione"; export const PagamentoRicezioneMailTag = "pagamentoRicezione";
};
const PagamentoRicezione = () => { const PagamentoRicezione = () => {
return ( return (

View file

@ -1,14 +1,14 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base"; import Base from "./base";
export type PwResetLink_NewMail = {
mailType: "pwResetLink";
props: PwResetLinkProps;
};
type PwResetLinkProps = { export const PwResetLinkMailTag = "pwResetLink";
resetlink: string;
}; export const PwResetLinkPropsSchema = z.object({
const PwResetLink = ({ resetlink }: PwResetLinkProps) => { resetlink: z.string(),
});
const PwResetLink = ({ resetlink }: z.infer<typeof PwResetLinkPropsSchema>) => {
return ( return (
<Base preview="Link Reset Password"> <Base preview="Link Reset Password">
<> <>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type Recesso_NewMail = {
mailType: "recesso"; export const RecessoMailTag = "recesso";
};
const Recesso = () => { const Recesso = () => {
return ( return (
<Base preview="Richiesta di recesso"> <Base preview="Richiesta di recesso">

View file

@ -1,10 +1,8 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import { env } from "~/env"; import { env } from "~/env";
import Base from "./base"; import Base from "./base";
export type RegistrazioneAvvenuta_NewMail = {
mailType: "registrazioneAvvenuta";
};
export const RegistrazioneAvvenutaMailTag = "registrazioneAvvenuta";
const RegistrazioneAvvenuta = () => { const RegistrazioneAvvenuta = () => {
return ( return (
<Base preview="Registrazione Avvenuta"> <Base preview="Registrazione Avvenuta">

View file

@ -1,20 +1,18 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import z from "zod";
import Base from "./base"; import Base from "./base";
export type SchedaContatto_NewMail = {
mailType: "emailSchedaContatto";
props: EmailSchedaContattoProps;
};
type EmailSchedaContattoProps = {
nominativo: string;
telefono: string;
indirizzo: string;
};
export const SchedaContattoMailTag = "emailSchedaContatto";
export const SchedaContattoPropsSchema = z.object({
nominativo: z.string(),
telefono: z.string(),
indirizzo: z.string(),
});
const EmailSchedaContatto = ({ const EmailSchedaContatto = ({
nominativo, nominativo,
telefono, telefono,
indirizzo, indirizzo,
}: EmailSchedaContattoProps) => { }: z.infer<typeof SchedaContattoPropsSchema>) => {
return ( return (
<Base noreply={true} preview="Dati Contatto Annuncio - Infoalloggi.it"> <Base noreply={true} preview="Dati Contatto Annuncio - Infoalloggi.it">
<> <>

View file

@ -1,8 +1,7 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type ServizioAttivato_NewMail = {
mailType: "servizioAttivato"; export const ServizioAttivatoMailTag = "servizioAttivato";
};
const ServizioAttivato = () => { const ServizioAttivato = () => {
return ( return (

View file

@ -38,7 +38,8 @@ export const EmailAccordion = ({
const iframeHeight = mobile ? 600 : 500; const iframeHeight = mobile ? 600 : 500;
return ( return (
<div className="mx-auto w-full py-4"> <div className="mx-auto w-full space-y-2 py-4">
<h3>Email inviate:</h3>
<Accordion <Accordion
className="space-y-4" className="space-y-4"
collapsible collapsible

View file

@ -12,6 +12,16 @@ import {
import { Label } from "src/components/ui/label"; import { Label } from "src/components/ui/label";
import { cn } from "src/lib/utils"; import { cn } from "src/lib/utils";
/*
React Hook Form DevTools, only in development mode.
const DevT: React.ElementType = dynamic(
() => import('@hookform/devtools').then((module) => module.DevTool),
{ ssr: false }
);
*/
const Form = FormProvider; const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
@ -171,12 +181,12 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = "FormMessage"; FormMessage.displayName = "FormMessage";
export { export {
useFormField,
Form, Form,
FormItem,
FormLabel,
FormControl, FormControl,
FormDescription, FormDescription,
FormMessage,
FormField, FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
}; };

View file

@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return ( return (
<textarea <textarea
className={cn( className={cn(
`flex min-h-20 w-full rounded-lg border border-border bg-background px-3 py-2 shadow-sm outline-hidden transition-all duration-200 hover:border-muted-foreground focus:border-foreground focus:ring-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:border-muted-foreground dark:hover:bg-input/50 dark:placeholder:text-neutral-400`, `flex min-h-20 w-full rounded-lg border border-border bg-card px-3 py-2 shadow-sm outline-hidden transition-all duration-200 hover:border-muted-foreground focus:border-foreground focus:ring-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-input dark:bg-input/30 dark:hover:border-muted-foreground dark:hover:bg-input/50 dark:placeholder:text-neutral-400`,
className, className,
)} )}
data-slot="textarea" data-slot="textarea"

View file

@ -0,0 +1,165 @@
import { format } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronDownIcon } from "lucide-react";
import { z } from "zod";
import { Confirm } from "~/components/confirm";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import Input from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Textarea } from "~/components/ui/textarea";
import { useZodForm } from "~/lib/zodForm";
import { useTranslation } from "~/providers/I18nProvider";
import type { EventQueue, EventQueueUpdate } from "~/schemas/public/EventQueue";
const Schema = z.object({
lock_date: z.date(),
lock_time: z.string(),
data: z.string(),
});
type FormValues = z.infer<typeof Schema>;
export const FormEditQueueMail = ({
data,
submitMutation,
del,
}: {
data: EventQueue;
submitMutation: (fields: EventQueueUpdate) => void;
del: () => void;
}) => {
const { locale, t } = useTranslation();
z.config(z.locales[locale]());
const form = useZodForm(Schema, {
defaultValues: {
lock_date: new Date(data.lock_expires),
lock_time: format(new Date(data.lock_expires), "HH:mm:ss"),
data: JSON.stringify(data.data, null, 2),
},
});
function onSubmit(fields: FormValues) {
submitMutation({
data: fields.data,
lock_expires: new Date(
format(fields.lock_date, "yyyy-MM-dd", { locale: it }) +
"T" +
fields.lock_time,
),
});
}
return (
<Form {...form}>
<form className="space-y-8 px-0.5" onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid w-full grid-cols-2 items-center gap-4">
<FormField
control={form.control}
name="lock_date"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Data Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Popover>
<PopoverTrigger asChild>
<Button
className="w-full justify-between bg-card font-normal"
id="date-picker-optional"
variant="outline"
>
{field.value
? format(field.value, "PPP", { locale: it })
: "Seleziona data"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden p-0"
>
<Calendar
captionLayout="dropdown"
defaultMonth={field.value}
mode="single"
onSelect={(date) => {
field.onChange(date);
}}
selected={field.value}
/>
</PopoverContent>
</Popover>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lock_time"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Ora Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
defaultValue={field.value}
id="time-picker-optional"
onChange={(e) => field.onChange(e.target.value)}
step="1"
type="time"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="data"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="data">{t.contact_form.messaggio}</FormLabel>
<FormMessage />
</div>
<FormControl>
<Textarea className="min-h-50" {...field} />
</FormControl>
</FormItem>
)}
/>
<div className="flex w-full justify-end gap-2">
<Button type="submit">Salva</Button>
<Confirm
description="Sei sicuro di voler svuotare la coda email?"
onConfirm={del}
title="Elimina"
>
<Button aria-label="Delete" variant="destructive">
Rimuovi dalla coda
</Button>
</Confirm>
</div>
</form>
</Form>
);
};

View file

@ -0,0 +1,421 @@
import { format, isAfter } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronDownIcon } from "lucide-react";
import { useEffect } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { z } from "zod/v4";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/custom_ui/form";
import { MultiSelect } from "~/components/custom_ui/multiselect";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import Input from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { useZodForm } from "~/lib/zodForm";
import {
type MailsTemplates,
TemplateKeys,
Templates,
type TemplateType,
} from "~/server/services/mail-templates";
type FormProps = {
submitMutation: (values: {
lock_expires: Date;
template: MailsTemplates;
subject: string;
}) => void;
};
const Schema = z
.object({
expires_date: z.date().refine((d) => isAfter(d, new Date()), {
message: "La data deve essere nel futuro",
}),
expires_time: z.string(),
mailType: z.enum(TemplateKeys),
mailProps: z.record(z.string(), z.unknown()).optional(),
subject: z.string(),
})
.superRefine((data, ctx) => {
const propsSchema = Templates[data.mailType as TemplateType].props;
if (propsSchema) {
const result = propsSchema.safeParse(data.mailProps);
if (!result.success) {
for (const issue of result.error.issues) {
ctx.addIssue({
...issue,
path: ["mailProps", ...(issue.path ?? [])],
});
}
}
}
});
type FormValues = z.infer<typeof Schema>;
export const FormAddCodaEmail = ({ submitMutation }: FormProps) => {
z.config(z.locales.it());
const form = useZodForm(Schema, {
defaultValues: {
expires_date: new Date(),
expires_time: "00:00:00",
mailType: TemplateKeys[0],
mailProps: undefined,
subject: "",
},
});
function onSubmit(fields: FormValues) {
const template = {
mailType: fields.mailType,
props: fields.mailProps as MailsTemplates["props"],
} as MailsTemplates;
const values: {
lock_expires: Date;
template: MailsTemplates;
subject: string;
} = {
lock_expires: new Date(
`${fields.expires_date.toISOString().split("T")[0]}T${fields.expires_time}`,
),
template,
subject: fields.subject,
};
submitMutation(values);
}
const type = form.watch("mailType");
useEffect(() => {
form.setValue("mailProps", {});
}, [type, form]);
useWatch({
name: "mailProps",
control: form.control,
compute: (props) => {
if (!props) return;
if ("title" in props) {
form.setValue("subject", props.title as string);
return;
}
},
});
return (
<>
<Form {...form}>
<form
className="space-y-8 px-0.5"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="grid w-full grid-cols-2 items-center gap-4">
<FormField
control={form.control}
name="expires_date"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Data Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Popover>
<PopoverTrigger asChild>
<Button
className="w-full justify-between bg-card font-normal"
id="date-picker-optional"
variant="outline"
>
{field.value
? format(field.value, "PPP", { locale: it })
: "Seleziona data"}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden p-0"
>
<Calendar
captionLayout="dropdown"
defaultMonth={field.value}
mode="single"
onSelect={(date) => {
field.onChange(date);
}}
selected={field.value}
/>
</PopoverContent>
</Popover>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="expires_time"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="titolo">Ora Invio</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
defaultValue="10:30:00"
id="time-picker-optional"
onChange={(e) => field.onChange(e.target.value)}
step="1"
type="time"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="mailType"
render={({ field }) => (
<FormItem className="w-full">
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="select-mailType">Modello</FormLabel>
<FormMessage />
</div>
<FormControl>
<MultiSelect
defaultValue={
field.value
? {
label: Templates[field.value].title,
value: field.value,
}
: undefined
}
elementId="select-mailType"
elementName="mailType"
isMulti={false}
onValueChange={(value) => {
field.onChange(value.value as TemplateType);
}}
options={TemplateKeys.map((key) => ({
label: Templates[key].title,
value: key,
}))}
placeholder="Seleziona..."
/>
</FormControl>
</FormItem>
)}
/>
<MailPropSection mailType={type as TemplateType} />
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel htmlFor="subject">Oggetto</FormLabel>
<FormMessage />
</div>
<FormControl>
<Input placeholder="" {...field} id="subject" />
</FormControl>
</FormItem>
)}
/>
<Button type="submit">Salva</Button>
</form>
</Form>
</>
);
};
// --- Zod v4 schema introspection helpers ---
function getType(schema: unknown): string {
// biome-ignore lint/suspicious/noExplicitAny: Zod v4 internal introspection
return (schema as any)?.type ?? "unknown";
}
function unwrap(schema: unknown): unknown {
const t = getType(schema);
if (t === "optional" || t === "nullable")
// biome-ignore lint/suspicious/noExplicitAny: Zod v4 internal introspection
return unwrap((schema as any).def.innerType);
return schema;
}
function extractShape(schema: unknown): Record<string, unknown> {
if (!schema || typeof schema !== "object") return {};
// biome-ignore lint/suspicious/noExplicitAny: Zod v4 internal introspection
const s = schema as any;
const t = getType(s);
if (t === "optional" || t === "nullable")
return extractShape(s.def.innerType);
if (s.shape && typeof s.shape === "object") return s.shape;
if (t === "intersection")
return { ...extractShape(s.def.left), ...extractShape(s.def.right) };
if (t === "union" && Array.isArray(s.def.options)) {
const merged: Record<string, unknown> = {};
for (const opt of s.def.options) {
for (const [k, v] of Object.entries(extractShape(opt))) {
if (!merged[k] || getFieldType(merged[k]) === "skip") merged[k] = v;
}
}
return merged;
}
return {};
}
type FieldType = "text" | "checkbox" | "number" | "object" | "skip";
function getFieldType(schema: unknown): FieldType {
const inner = unwrap(schema);
const t = getType(inner);
switch (t) {
case "boolean":
return "checkbox";
case "number":
return "number";
case "object":
return "object";
case "custom":
case "undefined":
case "unknown":
return "skip";
default:
return "text";
}
}
function isOptionalField(schema: unknown): boolean {
const t = getType(schema);
return t === "optional" || t === "nullable";
}
const FieldRenderer = ({
fieldSchema,
name,
label,
}: {
fieldSchema: unknown;
name: string;
label: string;
}) => {
const { control } = useFormContext();
const fieldType = getFieldType(fieldSchema);
const optional = isOptionalField(fieldSchema);
if (fieldType === "skip") return null;
if (fieldType === "object") {
const nestedShape = extractShape(fieldSchema);
const entries = Object.entries(nestedShape);
if (entries.length === 0) return null;
return (
<fieldset className="space-y-3 rounded-md border p-3">
<legend className="px-1 font-medium text-sm">
{label}
{optional && (
<span className="ml-1 text-muted-foreground text-xs">
(opzionale)
</span>
)}
</legend>
{entries.map(([key, nested]) => (
<FieldRenderer
fieldSchema={nested}
key={key}
label={key}
name={`${name}.${key}`}
/>
))}
</fieldset>
);
}
return (
<FormField
control={control}
// biome-ignore lint/suspicious/noExplicitAny: dynamic field name
name={name as any}
render={({ field }) => (
<FormItem>
<div className="flex flex-wrap items-center gap-x-2">
<FormLabel>
{label}
{optional && (
<span className="ml-1 text-muted-foreground text-xs">
(opzionale)
</span>
)}
</FormLabel>
<FormMessage />
</div>
<FormControl>
{fieldType === "checkbox" ? (
<input
checked={!!(field.value as boolean)}
className="h-4 w-4"
onChange={(e) => field.onChange(e.target.checked)}
type="checkbox"
/>
) : (
<Input
onChange={
fieldType === "number"
? (e) => field.onChange(Number(e.target.value))
: field.onChange
}
type={fieldType}
value={(field.value as string | number) ?? ""}
/>
)}
</FormControl>
</FormItem>
)}
/>
);
};
const MailPropSection = ({ mailType }: { mailType: TemplateType }) => {
const template = Templates[mailType];
if (!template.props) return null;
const shape = extractShape(template.props);
const entries = Object.entries(shape);
if (entries.length === 0) return null;
return (
<div className="space-y-4">
{entries.map(([key, fieldSchema]) => (
<FieldRenderer
fieldSchema={fieldSchema}
key={key}
label={key}
name={`mailProps.${key}`}
/>
))}
</div>
);
};

View file

@ -26,9 +26,9 @@ const AdminPotenziali: NextPageWithLayout = () => {
<Head> <Head>
<title>Potenziali - Infoalloggi.it</title> <title>Potenziali - Infoalloggi.it</title>
</Head> </Head>
<div className="mx-1 flex-1 overflow-auto"> <div className="mx-1 w-full flex-1">
<div className="mx-auto pt-4"> <div className="mx-auto w-full pt-4">
<div className="mx-3 flex-wrap items-start justify-between pb-5 sm:flex"> <div className="mx-3 flex flex-wrap items-start justify-between pb-5">
<div className="flex max-w-lg items-center gap-3"> <div className="flex max-w-lg items-center gap-3">
<h3 className="font-bold text-accent-foreground text-xl sm:text-2xl"> <h3 className="font-bold text-accent-foreground text-xl sm:text-2xl">
Potenziali Potenziali

View file

@ -1,10 +1,19 @@
import { MoreVertical, Trash2 } from "lucide-react"; import { Maximize2, MoreVertical, Plus, Trash2 } from "lucide-react";
import type { GetServerSideProps } from "next"; import type { GetServerSideProps } from "next";
import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { EmailAccordion } from "~/components/area-riservata/comunicazioni"; import { EmailAccordion } from "~/components/area-riservata/comunicazioni";
import { Confirm } from "~/components/confirm"; import { Confirm } from "~/components/confirm";
import { AreaRiservataLayoutUserView } from "~/components/Layout"; import { AreaRiservataLayoutUserView } from "~/components/Layout";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { import {
Popover, Popover,
@ -12,9 +21,13 @@ import {
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { FormEditQueueMail } from "~/forms/FormEditQueueMail";
import { FormAddCodaEmail } from "~/forms/FormNewMail";
import type { NextPageWithLayout } from "~/pages/_app"; import type { NextPageWithLayout } from "~/pages/_app";
import { useEnforcedSession } from "~/providers/SessionProvider"; import { useEnforcedSession } from "~/providers/SessionProvider";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
import type { Email_EventData } from "~/server/controllers/event_queue.controller";
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
import { zUserId } from "~/server/utils/zod_types"; import { zUserId } from "~/server/utils/zod_types";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@ -77,6 +90,7 @@ const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
</Popover> </Popover>
</div> </div>
<CommsSwitch userId={userId} /> <CommsSwitch userId={userId} />
<Coda userId={userId} />
<EmailAccordion isAdmin={user.isAdmin} userId={userId} /> <EmailAccordion isAdmin={user.isAdmin} userId={userId} />
</main> </main>
); );
@ -106,6 +120,9 @@ export const getServerSideProps = (async (context) => {
await helpers.trpc.comunicazioni.getEmails.prefetch({ await helpers.trpc.comunicazioni.getEmails.prefetch({
userId: parsed.data, userId: parsed.data,
}); });
await helpers.trpc.comunicazioni.getUserQueuedEmails.prefetch({
userId: parsed.data,
});
return { return {
props: { props: {
userId: parsed.data, userId: parsed.data,
@ -154,3 +171,159 @@ const CommsSwitch = ({ userId }: { userId: UsersId }) => {
</div> </div>
); );
}; };
const Coda = ({ userId }: { userId: UsersId }) => {
const { data, isLoading } = api.comunicazioni.getUserQueuedEmails.useQuery({
userId,
});
const utils = api.useUtils();
const { mutate } = api.comunicazioni.clearUserEmailQueue.useMutation({
onSuccess: async () => {
toast.success("Coda email pulita");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante la pulizia della coda email");
},
});
const { mutate: removeFromQueue } =
api.comunicazioni.removeFromUserEmailQueue.useMutation({
onSuccess: async () => {
toast.success("Email rimossa dalla coda");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante la rimozione dell'email dalla coda");
},
});
const { mutate: addToQueue } =
api.comunicazioni.addToUserEmailQueue.useMutation({
onSuccess: async () => {
toast.success("Email aggiunta alla coda");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante l'aggiunta dell'email alla coda");
},
});
const { mutate: updateEvent } = api.comunicazioni.updateEvent.useMutation({
onSuccess: async () => {
toast.success("Email aggiornata nella coda");
await utils.comunicazioni.getUserQueuedEmails.invalidate({ userId });
},
onError: (error) => {
console.error(error);
toast.error("Errore durante l'aggiornamento dell'email nella coda");
},
});
const [newOpen, setNewOpen] = useState(false);
if (isLoading) {
return <div>Loading...</div>;
}
if (!data) {
return <div>Errore nel caricamento coda delle email</div>;
}
return (
<div className="flex w-full flex-col gap-2">
<h3>Email in coda:</h3>
{data.length === 0 && <div>Nessuna email in coda</div>}
{data.map((email) => {
const mailData = email.data as Email_EventData;
return (
<div
className="flex items-center gap-2 rounded-md border p-1.5"
key={email.event_id}
>
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Maximize2 />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Dettagli</DialogTitle>
<DialogDescription className="sr-only">
dettagli dell'email in coda
</DialogDescription>
</DialogHeader>
<FormEditQueueMail
data={email}
del={() => removeFromQueue({ eventId: email.event_id })}
submitMutation={(values) => {
updateEvent({ eventId: email.event_id, data: values });
}}
/>
</DialogContent>
</Dialog>
<div className="font-semibold">
{mailData.data.mail.subject ||
mailData.data.template?.mailType ||
"Nessun oggetto"}
</div>
<div className="text-muted-foreground text-sm">
data invio:{" "}
{new Date(email.lock_expires).toLocaleDateString("it-IT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
);
})}
<div className="flex w-full justify-start gap-2">
<Dialog onOpenChange={setNewOpen} open={newOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus /> Aggiungi email alla coda
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Aggiungi email alla coda</DialogTitle>
<DialogDescription className="sr-only">
aggiungi email alla coda
</DialogDescription>
</DialogHeader>
<FormAddCodaEmail
submitMutation={(values) => {
addToQueue({ userId, ...values });
setNewOpen(false);
}}
/>
</DialogContent>
</Dialog>
<Confirm
description="Sei sicuro di voler svuotare la coda email?"
onConfirm={() => {
mutate({ userId });
}}
title="Elimina"
>
<Button
aria-label="Delete"
disabled={data.length === 0}
size="sm"
variant="destructive"
>
<Trash2 />
Pulisci coda email
</Button>
</Confirm>
</div>
</div>
);
};

View file

@ -1,5 +1,7 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { EmailsIdEmail } from "~/schemas/public/Emails"; import type { EmailsIdEmail } from "~/schemas/public/Emails";
import type { EventQueueUpdate } from "~/schemas/public/EventQueue";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import { import {
adminProcedure, adminProcedure,
@ -9,6 +11,9 @@ import {
} from "~/server/api/trpc"; } from "~/server/api/trpc";
import { import {
addEventToQueue, addEventToQueue,
clearUserEmailQueue,
removeEventFromQueue,
updateEvent,
userEmailQueue, userEmailQueue,
} from "~/server/controllers/event_queue.controller"; } from "~/server/controllers/event_queue.controller";
import { UtenteInteressatoAdAnnuncioEmail } from "~/server/controllers/servizio.controller"; import { UtenteInteressatoAdAnnuncioEmail } from "~/server/controllers/servizio.controller";
@ -18,10 +23,15 @@ import {
deleteEmail, deleteEmail,
getEmails, getEmails,
} from "~/server/services/email.service"; } from "~/server/services/email.service";
import { NewMail } from "~/server/services/mailer"; import { type MailsTemplates, NewMail } from "~/server/services/mailer";
import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service"; import { CONDIZIONI_DEFAULT } from "~/server/services/prezziario.service";
import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service"; import { genPdfSchedaAnnuncioBase64 } from "~/server/services/puppeteer.service";
import { zAnnuncioId, zOrdineId, zUserId } from "~/server/utils/zod_types"; import {
zAnnuncioId,
zEventId,
zOrdineId,
zUserId,
} from "~/server/utils/zod_types";
export const comunicazioniRouter = createTRPCRouter({ export const comunicazioniRouter = createTRPCRouter({
getEmails: protectedProcedure getEmails: protectedProcedure
@ -130,9 +140,11 @@ export const comunicazioniRouter = createTRPCRouter({
}, },
}); });
} catch (e) { } catch (e) {
throw new Error( throw new TRPCError({
`Errore nell'invio dell'email: ${(e as Error).message}`, code: "INTERNAL_SERVER_ERROR",
); message: `Errore nell'invio dell'email: ${(e as Error).message}`,
cause: e instanceof Error ? e : undefined,
});
} }
}), }),
@ -210,9 +222,11 @@ export const comunicazioniRouter = createTRPCRouter({
lock_expires: new Date(Date.now() + 3000), // Lock for 30 sec lock_expires: new Date(Date.now() + 3000), // Lock for 30 sec
}); });
} catch (e) { } catch (e) {
throw new Error( throw new TRPCError({
`Errore nell'invio dell'email: ${(e as Error).message}`, code: "INTERNAL_SERVER_ERROR",
); message: `Errore nell'aggiunta dell'email alla coda: ${(e as Error).message}`,
cause: e instanceof Error ? e : undefined,
});
} }
}), }),
getUserQueuedEmails: protectedProcedure getUserQueuedEmails: protectedProcedure
@ -220,4 +234,73 @@ export const comunicazioniRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await userEmailQueue(input.userId); return await userEmailQueue(input.userId);
}), }),
addToUserEmailQueue: adminProcedure
.input(
z.object({
lock_expires: z.date(),
userId: zUserId,
template: z.custom<MailsTemplates>(),
subject: z.string(),
}),
)
.mutation(async ({ input }) => {
try {
const user = await db
.selectFrom("users")
.selectAll()
.where("id", "=", input.userId)
.executeTakeFirstOrThrow(() => {
throw new Error(`User with id ${input.userId} not found`);
});
await addEventToQueue({
data: {
tipo: "email",
data: {
template: input.template,
mail: {
to: user.email,
subject: input.subject,
},
userId: input.userId,
},
},
lock_expires: input.lock_expires,
});
return { success: true };
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Errore nell'aggiunta dell'email alla coda: ${
error instanceof Error ? error.message : "Errore sconosciuto"
}`,
cause: error instanceof Error ? error : undefined,
});
}
}),
removeFromUserEmailQueue: adminProcedure
.input(
z.object({
eventId: zEventId,
}),
)
.mutation(async ({ input }) => {
return await removeEventFromQueue(input.eventId);
}),
clearUserEmailQueue: adminProcedure
.input(z.object({ userId: zUserId }))
.mutation(async ({ input }) => {
return await clearUserEmailQueue(input.userId);
}),
updateEvent: adminProcedure
.input(
z.object({
eventId: zEventId,
data: z.custom<EventQueueUpdate>(),
}),
)
.mutation(async ({ input }) => {
return await updateEvent(input.eventId, input.data);
}),
}); });

View file

@ -4,7 +4,11 @@ import { add, set } from "date-fns";
import { sql } from "kysely"; import { sql } from "kysely";
import type { Attachment } from "nodemailer/lib/mailer"; import type { Attachment } from "nodemailer/lib/mailer";
import type { AnnunciId } from "~/schemas/public/Annunci"; import type { AnnunciId } from "~/schemas/public/Annunci";
import type { NewEventQueue } from "~/schemas/public/EventQueue"; import type {
EventQueueEventId,
EventQueueUpdate,
NewEventQueue,
} from "~/schemas/public/EventQueue";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import type { UsersId } from "~/schemas/public/Users"; import type { UsersId } from "~/schemas/public/Users";
@ -26,7 +30,7 @@ type GeneratedAttachmentTypes =
| { type: "condizioni"; stringId: TestiEStringheStingaId }; | { type: "condizioni"; stringId: TestiEStringheStingaId };
type CustomAttachment = Attachment & { generate?: GeneratedAttachmentTypes }; type CustomAttachment = Attachment & { generate?: GeneratedAttachmentTypes };
type Email_EventData = { export type Email_EventData = {
tipo: "email"; tipo: "email";
data: NewMailProps & { data: NewMailProps & {
mail: { mail: {
@ -174,3 +178,55 @@ export const userEmailQueue = async (userId: UsersId) => {
}); });
} }
}; };
export const removeEventFromQueue = async (eventId: EventQueueEventId) => {
try {
await db
.deleteFrom("event_queue")
.where("event_id", "=", eventId)
.execute();
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while removing event from queue, ${(e as Error).message}`,
cause: e,
});
}
};
export const clearUserEmailQueue = async (userId: UsersId) => {
try {
await db
.deleteFrom("event_queue")
.where(sql`data->>'tipo'`, "=", "email")
.where(sql`data->'data'->>'userId'`, "=", userId)
.execute();
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while clearing user events from queue, ${(e as Error).message}`,
cause: e,
});
}
};
export const updateEvent = async (
eventId: EventQueueEventId,
data: EventQueueUpdate,
) => {
try {
await db
.updateTable("event_queue")
.set(data)
.where("event_id", "=", eventId)
.execute();
return true;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while updating event in queue, ${(e as Error).message}`,
cause: e,
});
}
};

View file

@ -25,6 +25,7 @@ import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import { import {
addEventToQueue, addEventToQueue,
canSendNotification, canSendNotification,
clearUserEmailQueue,
nextTimeForNotification, nextTimeForNotification,
} from "~/server/controllers/event_queue.controller"; } from "~/server/controllers/event_queue.controller";
import { import {
@ -830,7 +831,7 @@ export const SbloccaContatti = async ({
indirizzo: data.annuncio.indirizzo, indirizzo: data.annuncio.indirizzo,
nome_cognome: `${data.annuncio.locatore}${data.annuncio?.tipo_locatore ? ` (${data.annuncio.tipo_locatore})` : ""}`, nome_cognome: `${data.annuncio.locatore}${data.annuncio?.tipo_locatore ? ` (${data.annuncio.tipo_locatore})` : ""}`,
nome_cognome_utente: data.user.username, nome_cognome_utente: data.user.username,
sesso: data.anagrafica.sesso, sesso: data.anagrafica.sesso as "M" | "F" | null,
telefono: data.user.telefono, telefono: data.user.telefono,
}, },
}, },
@ -971,6 +972,9 @@ export const interruzioneServizio = async (
}, },
}); });
// cleanup coda notifiche: rimuove notifiche future relative a questo servizio
await clearUserEmailQueue(utente.id);
return true; return true;
} catch (e) { } catch (e) {
throw new TRPCError({ throw new TRPCError({

View file

@ -0,0 +1,108 @@
import {
AvvisoInterruzioneMailTag,
} from "emails/avviso-interruzione";
import {
ContattoAdminEmailPropsSchema,
ContattoAdminMailTag,
} from "emails/contatto";
import {
ContattoAnnuncioMailTag,
ContattoAnnuncioPropsSchema,
} from "emails/contatto_annuncio";
import {
ContattoInteressatoMailTag,
ContattoInteressatoPropsSchema,
} from "emails/email-interessato";
import {
GenericMailTag,
GenericPropsSchema,
} from "emails/gereric-email";
import { InterruzioneMailTag } from "emails/interruzione";
import { InvitoMailTag, InvitoPropsSchema } from "emails/invito";
import {
PagamentoConfermaMailTag,
} from "emails/pagamento-conferma";
import {
PagamentoErroreMailTag,
} from "emails/pagamento-errore";
import {
PagamentoRicezioneMailTag,
} from "emails/pagamento-ricezione";
import {
PwResetLinkMailTag,
PwResetLinkPropsSchema,
} from "emails/pw-reset-link";
import { RecessoMailTag } from "emails/recesso";
import {
RegistrazioneAvvenutaMailTag,
} from "emails/registrazione-avvenuta";
import {
SchedaContattoMailTag,
SchedaContattoPropsSchema,
} from "emails/scheda-annuncio";
import {
ServizioAttivatoMailTag,
} from "emails/servizio-attivato";
import {
VerificaEmailMailTag,
VerificaEmailPropsSchema,
} from "emails/VerificaEmail";
import type { ZodSchema, z } from "zod";
export const Templates = {
[GenericMailTag]: { title: "Email Generica", props: GenericPropsSchema },
[AvvisoInterruzioneMailTag]: { title: "Avviso Interruzione", props: null },
[ContattoAnnuncioMailTag]: {
title: "Contatto Annuncio",
props: ContattoAnnuncioPropsSchema,
},
[ContattoAdminMailTag]: {
title: "Contatto Amministratore",
props: ContattoAdminEmailPropsSchema,
},
[ContattoInteressatoMailTag]: {
title: "Contatto Interessato",
props: ContattoInteressatoPropsSchema,
},
[InterruzioneMailTag]: { title: "Interruzione Servizio", props: null },
[InvitoMailTag]: { title: "Invito al sito", props: InvitoPropsSchema },
[PagamentoConfermaMailTag]: { title: "Conferma pagamento", props: null },
[PagamentoErroreMailTag]: { title: "Errore pagamento", props: null },
[PagamentoRicezioneMailTag]: { title: "Ricezione pagamento", props: null },
[PwResetLinkMailTag]: {
title: "Reset password",
props: PwResetLinkPropsSchema,
},
[RecessoMailTag]: { title: "Richiesta di recesso", props: null },
[RegistrazioneAvvenutaMailTag]: {
title: "Registrazione avvenuta",
props: null,
},
[SchedaContattoMailTag]: {
title: "Scheda contatto annuncio",
props: SchedaContattoPropsSchema,
},
[ServizioAttivatoMailTag]: { title: "Servizio Attivato", props: null },
[VerificaEmailMailTag]: {
title: "Verifica email",
props: VerificaEmailPropsSchema,
},
} satisfies Record<string, { title: string; props: ZodSchema | null }>;
export const TemplateKeys = Object.keys(Templates) as Array<
keyof typeof Templates
>;
export const TemplateSchemas = Object.entries(Templates)
.map(([_, value]) => value.props)
.filter((schema) => schema !== null);
export type TemplateType = keyof typeof Templates;
export type MailsTemplates = {
[K in TemplateType]: {
mailType: K;
} & ((typeof Templates)[K] extends { props: z.ZodSchema }
? { props: z.infer<(typeof Templates)[K]["props"]> }
: { props?: never });
}[TemplateType];

View file

@ -1,41 +1,21 @@
import { render } from "@react-email/render"; import { render } from "@react-email/render";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { AvvisoInterruzione_NewMail } from "emails/avviso-interruzione";
import AvvisoInterruzione from "emails/avviso-interruzione"; import AvvisoInterruzione from "emails/avviso-interruzione";
import ContattoAdminEmail, { import ContattoAdminEmail from "emails/contatto";
type ContattoAdmin_NewMail,
} from "emails/contatto";
import type { ContattoAnnuncio_NewMail } from "emails/contatto_annuncio";
import ContattoAnnuncioEmail from "emails/contatto_annuncio"; import ContattoAnnuncioEmail from "emails/contatto_annuncio";
import EmailContattoInteressato, { import EmailContattoInteressato from "emails/email-interessato";
type ContattoInteressato_NewMail,
} from "emails/email-interessato";
import type { Generic_NewMail } from "emails/gereric-email";
import Generic from "emails/gereric-email"; import Generic from "emails/gereric-email";
import type { Interruzione_NewMail } from "emails/interruzione";
import Interruzione from "emails/interruzione"; import Interruzione from "emails/interruzione";
import type { Invito_NewMail } from "emails/invito";
import Invito from "emails/invito"; import Invito from "emails/invito";
import type { PagamentoConferma_NewMail } from "emails/pagamento-conferma";
import PagamentoConferma from "emails/pagamento-conferma"; import PagamentoConferma from "emails/pagamento-conferma";
import type { PagamentoErrore_NewMail } from "emails/pagamento-errore";
import PagamentoErrore from "emails/pagamento-errore"; import PagamentoErrore from "emails/pagamento-errore";
import type { PagamentoRicezione_NewMail } from "emails/pagamento-ricezione";
import PagamentoRicezione from "emails/pagamento-ricezione"; import PagamentoRicezione from "emails/pagamento-ricezione";
import type { PwResetLink_NewMail } from "emails/pw-reset-link";
import PwResetLink from "emails/pw-reset-link"; import PwResetLink from "emails/pw-reset-link";
import type { Recesso_NewMail } from "emails/recesso";
import Recesso from "emails/recesso"; import Recesso from "emails/recesso";
import type { RegistrazioneAvvenuta_NewMail } from "emails/registrazione-avvenuta";
import RegistrazioneAvvenuta from "emails/registrazione-avvenuta"; import RegistrazioneAvvenuta from "emails/registrazione-avvenuta";
import EmailSchedaContatto, { import EmailSchedaContatto from "emails/scheda-annuncio";
type SchedaContatto_NewMail,
} from "emails/scheda-annuncio";
import type { ServizioAttivato_NewMail } from "emails/servizio-attivato";
import ServizioAttivato from "emails/servizio-attivato"; import ServizioAttivato from "emails/servizio-attivato";
import VerificaEmail, { import VerificaEmail from "emails/VerificaEmail";
type VerificaEmail_NewMail,
} from "emails/VerificaEmail";
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import type { Options } from "nodemailer/lib/mailer"; import type { Options } from "nodemailer/lib/mailer";
import { htmlToText } from "nodemailer-html-to-text"; import { htmlToText } from "nodemailer-html-to-text";
@ -46,6 +26,19 @@ import { GetFlagValueHandler } from "~/server/controllers/flags.controller";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { storeEmail } from "~/server/services/email.service"; import { storeEmail } from "~/server/services/email.service";
export {
type MailsTemplates,
TemplateKeys,
TemplateSchemas,
Templates,
type TemplateType,
} from "~/server/services/mail-templates";
import {
type MailsTemplates,
Templates,
} from "~/server/services/mail-templates";
const mailer = async (options: Options) => { const mailer = async (options: Options) => {
const smtpTransport = createTransport({ const smtpTransport = createTransport({
auth: { auth: {
@ -81,117 +74,74 @@ export type NewMailProps = {
userId?: UsersId; userId?: UsersId;
mail: Options; mail: Options;
}; };
export type MailsTemplates =
| ContattoAdmin_NewMail
| ContattoAnnuncio_NewMail
| ContattoInteressato_NewMail
| PagamentoConferma_NewMail
| PagamentoErrore_NewMail
| PagamentoRicezione_NewMail
| PwResetLink_NewMail
| Recesso_NewMail
| RegistrazioneAvvenuta_NewMail
| VerificaEmail_NewMail
| Generic_NewMail
| SchedaContatto_NewMail
| Invito_NewMail
| ServizioAttivato_NewMail
| AvvisoInterruzione_NewMail
| Interruzione_NewMail;
export const genMail = async ({ ...mail }: MailsTemplates) => { export const genMail = async ({ ...mail }: MailsTemplates) => {
let mailComponent: JSX.Element | null = null; let mailComponent: JSX.Element | null = null;
let title = ""; const title = Templates[mail.mailType].title;
switch (mail.mailType) { switch (mail.mailType) {
case "contattoAdminEmail": case "contattoAdminEmail":
mailComponent = ContattoAdminEmail({ mailComponent = ContattoAdminEmail({
...mail.props, ...mail.props,
}); });
title = "Contatto amministratore";
break; break;
case "contattoAnnuncioEmail": case "contattoAnnuncioEmail":
mailComponent = ContattoAnnuncioEmail({ mailComponent = ContattoAnnuncioEmail({
...mail.props, ...mail.props,
}); });
title = "Contatto annuncio";
break; break;
case "emailContattoInteressato": case "emailContattoInteressato":
mailComponent = EmailContattoInteressato({ mailComponent = EmailContattoInteressato({
...mail.props, ...mail.props,
}); });
title = "Contatto interessato";
break; break;
case "pagamentoConferma": case "pagamentoConferma":
mailComponent = PagamentoConferma(); mailComponent = PagamentoConferma();
title = "Conferma pagamento";
break; break;
case "pagamentoErrore": case "pagamentoErrore":
mailComponent = PagamentoErrore(); mailComponent = PagamentoErrore();
title = "Errore pagamento";
break; break;
case "pagamentoRicezione": case "pagamentoRicezione":
mailComponent = PagamentoRicezione(); mailComponent = PagamentoRicezione();
title = "Ricezione pagamento";
break; break;
case "pwResetLink": case "pwResetLink":
mailComponent = PwResetLink({ mailComponent = PwResetLink({
...mail.props, ...mail.props,
}); });
title = "Reset password";
break; break;
case "recesso": case "recesso":
mailComponent = Recesso(); mailComponent = Recesso();
title = "Recesso";
break; break;
case "registrazioneAvvenuta": case "registrazioneAvvenuta":
mailComponent = RegistrazioneAvvenuta(); mailComponent = RegistrazioneAvvenuta();
title = "Registrazione avvenuta";
break; break;
case "verificaEmail": case "verificaEmail":
mailComponent = VerificaEmail({ mailComponent = VerificaEmail({
...mail.props, ...mail.props,
}); });
title = "Verifica email";
break; break;
case "generic": case "generic":
mailComponent = Generic({ mailComponent = Generic({
...mail.props, ...mail.props,
}); });
title = mail.props.title || "Email Generica";
break; break;
case "emailSchedaContatto": case "emailSchedaContatto":
mailComponent = EmailSchedaContatto({ mailComponent = EmailSchedaContatto({
...mail.props, ...mail.props,
}); });
title = "Scheda Contatto Annuncio";
break; break;
case "invito": case "invito":
mailComponent = Invito({ mailComponent = Invito({
...mail.props, ...mail.props,
}); });
title = "Invito al sito";
break; break;
case "servizioAttivato": case "servizioAttivato":
mailComponent = ServizioAttivato(); mailComponent = ServizioAttivato();
title = "Servizio Attivato";
break; break;
case "avvisoInterruzione": case "avvisoInterruzione":
mailComponent = AvvisoInterruzione(); mailComponent = AvvisoInterruzione();
title = "Avviso Interruzione";
break; break;
case "interruzione": case "interruzione":
mailComponent = Interruzione(); mailComponent = Interruzione();
title = "Interruzione servizio";
break; break;
default: default:
console.error(`Tipo di email non valido: ${mail satisfies never}`); console.error(`Tipo di email non valido: ${mail satisfies never}`);

View file

@ -4,6 +4,7 @@ import type { BanlistId } from "~/schemas/public/Banlist";
import BanType from "~/schemas/public/BanType"; import BanType from "~/schemas/public/BanType";
import type { ChatsChatid } from "~/schemas/public/Chats"; import type { ChatsChatid } from "~/schemas/public/Chats";
import type { EtichetteIdEtichetta } from "~/schemas/public/Etichette"; import type { EtichetteIdEtichetta } from "~/schemas/public/Etichette";
import type { EventQueueEventId } from "~/schemas/public/EventQueue";
import type { FlagsId } from "~/schemas/public/Flags"; import type { FlagsId } from "~/schemas/public/Flags";
import type { MessagesMessageid } from "~/schemas/public/Messages"; import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
@ -91,3 +92,8 @@ export const zRinnovoId = z.custom<RinnoviId>(
(val) => typeof val === "string", (val) => typeof val === "string",
"falied to validate RinnovoId", "falied to validate RinnovoId",
); );
export const zEventId = z.custom<EventQueueEventId>(
(val) => typeof val === "number",
"falied to validate EventId",
);